Aspen: execution plan
Aspen is a self-hostable Git server, written in Rust, built for agent-scale traffic. It follows the design OpenAI published for Pando ("Scaling Git at OpenAI", https://openai.com/index/scaling-git-at-openai/, 2026-11-02):
- packs are stored in object storage without being unpacked; a thin pushed pack is first completed into a self-contained pack (spike S3);
- branch history lives in a database, and each branch updates independently;
- a push sees only the trunk branch;
- fetches are served by selecting stored packs, never by building one per client;
- small packs are merged in the background in levels, like an LSM tree.
It also takes inspiration from Cursor's "Git at any scale" (https://cursor.com/blog/git-at-any-scale, August 2026): push throughput is measured against Cursor's published numbers, counting a push only once it is durable, and group commit batches the write that publishes pushes the way Cursor's design does (decisions log, "Group commit"). README.md, "Where the design comes from", says which idea came from which source.
Aspen is open source under the Apache License 2.0 and published at https://github.com/reason-machines/aspen by a regular sync from the maintainers' development repository. Write everything (code, docs, commit history) as if strangers will read it. This file is the source of truth for scope and sequencing. Update it when a spike or a review changes a decision, and record why.
Owner: Aspen, a coding agent that coordinates the work: it keeps this plan, reviews and merges every pull request, and runs each node in its own agent session (the "Agent session" column below). Requested by the maintainers, 2026-11-02.
Progress
The coordinator updates this table after every merge. States: ready,
in progress, in review, merged, blocked. Node IDs are defined in
section 3.
| Node | State | PR | Agent session |
|---|---|---|---|
| N0 Bootstrap: workspace + fast CI | merged | #1; follow-up #191 (39c5ff2, a CI run) fixes #189 (the required interop smoke failed whenever Launchpad's API was down; it now installs the latest Git without that API and fails unless the Git it tests is the latest release); follow-up #235 (a45ec4a, a CI run) fixes #72 and #207 (git's automatic maintenance repacked a test repository while git fsck read it; every test git client now turns it off), follow-up issue #244; follow-up #237 (a010b3d, a CI run) fixes #224 (rustdoc reported broken and private intra-doc links; required CI's clippy + rustdoc + layering job now denies rustdoc warnings) |
41f332f7 |
| S1 Spike: stitched packs vs clients | merged | #12 (2a59c6c) |
6cd863cd |
| S2 Spike: trunk-only push semantics | merged | #4 (7efaf66) |
7e0672a1 |
S3 Spike: packfile-uri across clients |
merged | #6 (0030de3) |
9097be06 |
S4 Spike: streaming gix-pack ingest |
merged | #15 (7fa04a5) |
318cc074 |
| S5 Spike: shallow and filter requests | merged | #3 (f784cb6) |
8c302a46 |
N1 Contracts in aspen-core plus schema v1, revised from the spikes |
merged | #18 (210a5b4) |
2b241aaf |
A1 aspen-proto |
merged | #22 (e365b60) |
441ded5f |
A1b v0 upload-pack in aspen-proto |
merged | #33 (93d13e2) |
52dc423a |
A2 aspen-pack: ingest, index, commit extraction, stitcher |
merged | #21 (c7e673b) |
5178c9ef |
A3 aspen-meta: Postgres + SQLite, compare-and-swap, commit index, jobs |
merged | #24 (224c75a) |
29ac2bb8 |
A4 aspen-blob |
merged | #20 (92b1ac9) |
2b550ea5 |
A5 aspen-planner + property tests against a git rev-list --objects oracle |
merged | #23 (7744ba7) |
d5146d0d |
A6 testkit: differential oracle, client matrix images |
merged | #9 (a118bec) |
999a339b |
A7 bench/: aspen-bench load generator plus baselines |
merged | #7 (371fda7) |
66f280fa |
A8 deploy/compose, plus Terraform AWS infrastructure |
merged | #5 (9255b3a), follow-up #10 (ddd64b6) |
8091b082 |
A9 demo/place front end against a mock API |
merged | #8 (c1b7fb3) |
404e7c60 |
| B1 receive-pack service; measures push latency with thin-pack completion | merged | #37 (cd8dd69) |
83c51478 |
B2 upload-pack v2: ls-refs, planning, packfile-uri + stitched fallback |
merged | #34 (fb6ac9e) |
0a93a6ec |
| B3 Auth, multi-repo, quotas, rate limits | merged | #31 (ef1ef85) |
d8e15304 |
| C1 Compaction worker + skip-list planning | merged | #73 (84da3dd) |
bcb02b90 |
C2 aspen-express + CDN mode |
merged | #46 (ba8fceb) |
d574ec88 |
| C3 SSH transport | merged | #52 (f1277a1) |
ba35b787 |
| C4 Mirror mode | merged | #53 (c4656f6) |
b38b1af1 |
C5 Observability: OTel, Prometheus, Grafana JSON; dead-job and wider-trunk-version metrics; aspen doctor jobs |
merged | #59 (019e296) |
52db382b |
| C6 Helm; Terraform for GCP, Azure and Hetzner+MinIO; release pipeline | merged | #51 (14397ef) |
5da83d44 |
C7 Demo back end: place repo rules, merger bot, live event feed |
merged | #54 (51109f2) |
c6fbe5f1 |
| C8 Exact cached shallow packs | merged | #104 (6578ec9), follow-ups #107, #108, #109, #110 |
d6e77ec2 |
| D1 Chaos suite | merged | #128 (5b495b2); nightly a CI run on 5b495b2: every scenario passed (compaction kill fired 33 times in compaction, 3 in mixed); follow-up #153; follow-up #169 (0056381, a CI run) fixes #132 (a push libcurl resent after a connection reset was reported rejected although it landed); follow-up #176 (ec16b61, a CI run) fixes #131 (a transient store error left a compaction job leased to an idle worker), and the same gap in mirror mode's worker is #188; #203 (chaos mixed stored 60 to 120 times the repository in the bucket, about 4.5 GB an hour) fixed in #231 (be53c39, a CI run) (a plain-URL branch push stores only what the branch lacks, and superseded packs are reclaimed within hours); #225 (a continuous 24-hour run did not fit the 63 GB host) stays open until a continuous 24-hour run confirms disk use; the launch gate's 24-hour mixed run awaits a host and budget from the maintainers |
66cce8c3 |
| D2 Benchmark campaign + write-up | merged | #148 (8b6b972); first campaign in bench/campaign/ (one 4-vCPU machine, server and clients together, memory resized from 8.35 GB to 16.94 GB partway through); nightly bench now runs stale-branch on every target and the >2 GiB .idx test on local; findings #149 (push latency grows with trunk history) and #150 (fetch storms exhaust the metadata pool); #149 fixed in #201 (0d86f87, a CI run passed) (a push's search packs are computed without walking trunk history); #150 fixed in #212 (ee0ded5, a CI run passed) (store operations queue for the metadata pool, so fetch storms wait instead of failing); follow-ups #156 and #157, fixed in #166 (b738d3e) and #167 (ea68648); launch-post latency numbers await a rerun on identical instances with separate client machines |
d6128e95 |
| D3 Fuzzing in nightly CI | merged | #130 (2e2c2cb); nightly fuzz.yml runs the 9 targets; #151 (ingest refused some valid thin pushes) fixed in #159 (26c8c64), its six inputs in fuzz/regressions/pack_ingest; #164 (property test for thin-pack completion shifts against git index-pack --fix-thin) fixed in #180 (68288d6, a CI run); #162 (ingest stored a delta with fewer than 4 bytes of data, which git refuses) fixed in #174 (5c0d243); #163 (two D1 chaos tests flaky under machine load) fixed in #168 (721f409); #129 (no test fed a known SHA-1 collision attack through the hasher) fixed in #213 (88aff5b, a CI run); follow-up issue #214; #139 (the HTTP and SSH front ends had no fuzz targets) fixed in #228 (632c116, a CI run passed), which adds four targets, so nightly fuzz.yml runs 13; follow-up #230 (the jwt target does not sign RS256/384/512 tokens) is open; launch gate: 24 h total fuzzing, at least 2.5 h per target, clean |
8a83f139 |
| D4 Security review of the public endpoint | merged | #115 (cf6fa05); security follow-up issues #118-#125 and #137, of which #118, #120, #124 and #125 must land before the demo goes public; follow-up #192 (ab148c5, a CI run) fixes #122 (every workflow action is pinned to a commit SHA and downloaded tools to a SHA-256, enforced in required CI by cargo xtask check-workflows); follow-up #196 (888d884, a CI run) fixes #123 (express cache fills are capped in number and reserve their pack's size against the cache budget before copying); follow-up #194 (1820597, a CI run) fixes #120 (SSH client addresses reach Aspen through the AWS NLB, which preserves client IPs, and the Helm SSH Service, with externalTrafficPolicy: Local); follow-up #199 (7ec2a01, a CI run) fixes #121 (X-Forwarded-For is read only when the TCP peer is a configured trusted proxy and is parsed strictly, so a client no longer chooses its failed-authentication bucket; follow-up issue #202); #137 (access_log's doc comment was attached to idle_bodies) fixed in #223 (ca69f1e, a CI run); follow-up #219 (5ae82cd, a CI run) fixes #124 (place's event streams are capped per client address and in all, and swarm starts are atomic); follow-up #210 (d5d37fb, a CI run) fixes #125 (every deploy path sets per-caller read and push rates by default, images are pinned by digest and checked in required CI, a secret file other users may read stops startup, and the Helm chart can limit its metrics port to listed peers with a NetworkPolicy); follow-up #236 (e199b5f, a CI run) fixes #198 (concurrent failed authentications overshot the limit; a presented credential is now charged before it is checked and refunded when it authenticates), follow-up issues #242 and #243 |
0c9d566a |
| D5 Public deployment | merged | #112 (944822b); not deployed: awaits isolated AWS account, subdomain, budget-alert emails, spend approval from the maintainers; public SSH was blocked on #120, fixed in #194 (1820597), and the demo still defaults to enable_ssh = false; follow-ups #140, #141, #142, #144, #145 and #146; #140 (the place server's shutdown drain left connections open) fixed in #187 (8e28b4d, a CI run); #144 (create-database.sh passed the place password in psql's argv) fixed in #205 (c46e4c5, a CI run), which reads it with psql's \getenv; follow-up issue #208; #145 (smoke.sh passed the token in git's argv) fixed in #209 (6233148, a CI run), which passes it through the environment and requires git 2.31 or later; #141 (no test for the place server's shutdown error path or its no-connections fast path) fixed in #216 (049dec8, a CI run); #142 (the public-demo runbook's SSH known limit predated #115) fixed in #220 (21a1e33, a CI run); #146 (the place web server panicked on a page/API route collision) fixed in #222 (e646656), which refuses a page file at a reserved path; its own main run was cancelled as superseded, and a CI run on ca69f1e covers it |
20d32199 |
| D6 Docs site, "run it yourself in 5 minutes", launch post draft | merged | #114 (ab12b95), follow-up #117 (78799f3); follow-up issue #116 |
d9f9138a |
| L0 Differential suite and client matrix against a real Aspen | merged | #136 (b85fd62); #134 (receive-pack refused JGit's gzip push bodies with 415) fixed in #175 (643696d, a CI run), so the nightly JGit-against-Aspen client-matrix row gates again; nightly differential-long gains an aspen, postgres row; follow-up #172 (afb2e88) fixes #154 and #161 (nightly all-tests ran no ignored tests past aspen-pack) and #155 (git-latest matrix row; verified and closed on nightly a CI run, main 4213a6a, passed); follow-up #170 (96611bd, a CI run) fixes #133 (delete of a missing ref), the oracle's last known divergence; follow-ups #178 (8b31f3a) and #184 (4213a6a) fix #177 and #183 (nightly all-tests ran out of disk in the over-2-GiB .idx test), verified by nightly a CI run; follow-up #173 (48849cf, a CI run) fixes #135 (the oracle also pushes and races through the plain repository URL) |
276317e2 |
1. Product
"A Git server that stays fast when a thousand agents push at once, and that you can run yourself on any cloud in five minutes."
Target users: teams whose agents, CI and humans hammer one repository. They run Aspen in one of two ways:
- Primary mode: Aspen is the Git host.
- Mirror mode: Aspen sits in front of GitHub or GitLab. Fetches come from Aspen's own packs, and pushes are forwarded upstream either synchronously (proxy) or in the background (Aspen is primary, upstream is a downstream mirror).
Mirror mode is the main adoption path, because nobody has to migrate.
Goals
- Works with stock
git, over HTTP and SSH, with no client changes required. - Pushes to different branches scale horizontally. The only contended write is a compare-and-swap on one branch's tip.
- Reads scale to the network limit. Clients that set
git config fetch.uriprotocols httpsdownload packs straight from object storage or a CDN. - Runs anywhere: one binary, Postgres, and any S3-compatible bucket (S3, GCS, Azure Blob, R2, MinIO, or the local filesystem).
- Reliability is proven by differential testing against stock Git, chaos testing and fuzzing, not asserted.
Not in v1 (document each one, and give a clear error where needed)
- Partial-clone filters: don't advertise
filter, so clients fall back to a full fetch (verified in S5). Lazy fetches from existing partial clones (want <blob-oid>) get a clearERR; mirror mode may forward them upstream later. - Exact shallow packs for every deepen form: a plain
--depth=Nfetch of a ref version's tip (N up to 64) is served an exact pack, built once per version and depth (node C8).--deepen(relative),--shallow-since,--shallow-exclude,--unshallow, deeper depths and non-tip wants are still served the full history of the wants plus an exactshallow-info(S5). Operators can setshallow = refuseto answer with a clearERRinstead, orASPEN_SHALLOW_PACKS=falseto serve every shallow fetch the full history. - SHA-256 repositories.
- A web UI for browsing code.
- Code review or issues.
2. Architecture
git client ─HTTP/SSH─► aspen-server ─CAS per ref─► Postgres
│ (refs, ref_versions, commit_index, covers)
│ stream+validate
▼
object store (S3/GCS/Azure/R2/MinIO/fs) ◄── aspen-compactor (L0→L4 covers)
▲
git client ◄─packfile-uri── aspen-express (regional pack cache) / CDN
Data model (schema v1, N1)
The tables below are schema v1, in
crates/aspen-meta/migrations (Postgres and
SQLite, kept identical) and described column by column in
crates/aspen-meta/SCHEMA.md. The Rust
contracts are in crates/aspen-core. Object IDs are 20-byte binary,
timestamps are microseconds since the epoch, and every cross-table reference
inside a repository is a composite foreign key through repo_id.
| Table | Holds |
|---|---|
repos |
Repository settings, including trunk ref, force policy (allow_force: none, branches (default; never trunk) or all; a forced trunk update keeps trunk exact under B1's forced-trunk rule, decisions log) and writable ref namespaces (default refs/heads/*, refs/tags/*) |
refs |
(repo, name) → current ref_version_id, seq; this is the only compare-and-swap target, and its foreign key on (version, repo, name, seq) plus SET seq = seq + 1 lets a swap land only on the expected version's child. A deleted ref keeps its row, pointing at the deletion version |
ref_versions |
id, repo, ref, seq, parent_version, tip_oid, closure_exact, pack_id, trunk_dep, scoped_dep, created_at. closure_exact says whether the version's closure is exactly what its tip reaches, which decides whether a fetch have may stand for it (fetch::HaveVersion); it is computed per write against the pack that write's version names, which B1's placement rule chooses (decisions log, "where a write's objects are stored"). seq numbers a ref's versions from 1, and parent_version is the version with seq - 1. tip_oid null is a deletion; pack_id null means the push stored no pack. The deps are typed: trunk_dep is the exact trunk version the push's lookup and connectivity check searched (push path, step 4), scoped_dep the scoped ref's old version for a scoped-URL push (S2), and for a client's push of one branch through the plain repository URL that is not forced or leased, that branch's version the push searched, so it stores only what the branch's history lacks (issue #203). Parent and deps have smaller IDs, so the graph is acyclic. Invariant: every committed ref version is connected: every object reachable from tip_oid exists in its pack or its deps' closure (enforced by the push connectivity check) |
packs |
id, repo, checksum, generation, pack_key, size, idx_key, idx_size, entries_key, entries_size, object_count, ref_delta_count, level, origin, received_bytes. Keys are repos/<repo>/packs/<checksum>.{pack,idx,entries} for generation 0 and <checksum>.g<n>.{pack,idx,entries} for generation n, the next registration of a checksum whose earlier row garbage collection swept; checksum is unique per repository among live rows, and readers always take keys from the row, never from the checksum alone. received_bytes (push packs only) against size shows completion growth (S4); ref_delta_count gates no-ref-delta (S3). Writers register, then upload, then reference: register_pack returns the row (and so its generation and keys), the pack, .idx and entry table are uploaded to those keys, and only then may a version or cover name the row; so every row a version or cover names can be served, while an unnamed row may lack its blobs (decisions log, C1 pack generations). Invariant: every stored pack is self-contained: no delta base lies outside the pack (thin pushes are completed at ingest, S3; compaction writes self-contained packs). So any selected pack can be served alone: as a packfile URI, which the client indexes on its own, or as one segment of a stitched pack. Second invariant: every stored pack is base-first: each delta's base entry comes before the delta (S1; ingest places each completion base before the first delta that uses it and moves any received delta that precedes its base to just after it, and compaction must write base-first too). With both, the stitcher deduplicates in one streaming pass. A pack is not closed under reachability: objects its trees and commits reference may live in its deps closure |
commit_index |
(repo, commit_oid) → ref_version_id, parent oids (concatenated 20-byte IDs), committer time, written synchronously for the tip and asynchronously for the rest of a large push. ref_version_id is a version whose tip reaches the commit: it resolves a want, never a have. Parents and time serve shallow-info and fast-forward checks without reading packs (S5). A walk that reaches a commit with no row yet reads that commit from the packs of the ref version it came from (pack_id, then deps), which hold every ancestor of its tip; a missing row is never read as "no parents" or "not an ancestor". If the commit is in neither place, the request fails with a retryable error |
covers |
repo, ref, level (1–4), first_seq, last_seq, pack_id or null (null until compacted, and then a compaction pack at the cover's level): a range of one ref's version chain, aligned by CoverLayout (level k spans 16^k versions). Migration 0005 adds cover_chains: a cover whose range is a dependency chain (every version after the first depends on its parent) records the ID of its first version, which a foreign key ties to the cover's ref and first_seq. Covers follow a ref's version history, closures follow dependencies, so a fetch treats a cover's range as held for a have only when it carries a chain (decisions log, "C1: cover chains") |
shallow_packs |
Migration 0006 (node C8): repo, version, depth → pack_id, recorded_at_us, the exact shallow pack of a ref version at a depth: exactly the commits fewer than depth parents below the version's tip (the region), the tags the tip peels through, and every tree and blob the region's trees reach. The pack is an ordinary packs row (registered as a compaction pack at level 1 that no cover names) and obeys every stored-pack invariant. The first recording wins; the sweep counts a recorded pack as named; garbage collection evicts recordings of versions that are no longer current after a TTL (decisions log, C8 rows). Migration 0007 adds used_at_us, a recording's last use: garbage collection also evicts recordings unused for a day, current or not, and each repository's recorded packs stay under a byte cap, least recently used evicted first (issue #108) |
jobs |
Background work queue (index_commits, compact, mirror_push on a ref version; rewrite_pack on a pack that broke base-first), written in the push's own transaction and enqueued idempotently (one job that is not done per kind and target). States queued, running (leased, fenced on worker and attempt), done, dead (after 10 attempts, failed or expired). A dead job is requeued by resolving it and enqueueing a fresh job in one transaction, never by resetting attempts |
Push path
Confirmed against git 2.30, 2.39, 2.56 and OpenAI's fork by spike S2. The full rule table and the decisive wire excerpts are in bench/spikes/s2/RESULT.md.
-
The client connects and Aspen runs v0/v1 receive-pack. It advertises trunk as the only named ref, plus earlier trunk tips as
.havelines, orcapabilities^{}for an empty repository: every one of the newest8 × have_tipstrunk versions (64 by default), then tips about a quarter apart down to the oldest trunk version a push searches (meta::have_tip_seqs; at most 108 lines for a million versions). Without the.havelines, a client that has not fetched the latest trunk pushes its whole history; with them, a client any number of landings behind resends at most about a quarter of them (decisions log, "item 10"). Capabilities:report-status delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent.- A branch-scoped URL,
/<repo>.git/-/refs/heads/<branch>, also advertises that one ref. It is the opt-in route for stock--force-with-leaseandgit push --delete <branch>. push.negotiate. Through the plain URL, stock Git resends every commit of the pushed branch that is not on trunk. Withgit config push.negotiate trueit first runs a negotiation-only v2 fetch (wait-for-done, haves, no wants); Aspen advertiseswait-for-doneand acknowledges every have with acommit_indexrow, and Git leaves those commits out. A fast-forward of the pushed branch then needs nothing beyond its usual dependencies (issue #203's branch dependency). A push whose left-out commits are outside its dependencies (another branch's commits) is completed from their versions (decisions log, "push.negotiate").
- A branch-scoped URL,
-
Ingest produces a self-contained pack, which packfile URIs require (spike S3), and never unpacks it. It is synchronous and takes two passes:
- Pass 1 (receive): the incoming pack streams to a spool file on
local disk (a real disk, never tmpfs) while
gix-packparses it. It inflates every entry, checks each entry's CRC32, and checks the client's trailer with SHA-1 collision detection. Memory stays under 10 MiB at any pack size; the parse runs at about 110 MB/s per core (S4). The base IDs of all REF_DELTA entries are collected as candidate bases. A candidate may be a thin base (indeps) or an object in the same pack: libgit2 sends every delta as REF_DELTA, mostly against bases in the pack (S1'spush_order.py), and pass 1 cannot tell the two apart, because a deltified entry's ID is known only once it is resolved. A push whose header object count exceeds the limit is refused before its body is read. - Base prefetch: each candidate, with its delta chain, is looked up
in parallel before completion starts. The base-object lookup probes
the
.idxof the covering packs of every ref version advertised on the connection: trunk, plus the branch for a branch-scoped URL, which are exactly the push'sdeps(see S3's README). A candidate thatdepsdoesn't have is not an error: it is expected to be in the pack. Prefetching matters because gix asks for bases one at a time from inside its parse. A lookup that went to the blob store there would serialize thousands of round trips per push: S4 counted 5,431 entry reads for a 100-commit Linux push. The prefetch is designed, not measured: S4's harness used a synchronous lookup over a local pack, and B1 measures the prefetch against a real bucket. Every stored pack's delta chains are known before they are read (chain maps from its entry table: in memory beside a whole.idx, on the spool disk beside a sampled one), so all the entries of every candidate's chain are read in one round of parallel range reads, whatever the chain's depth (decisions log, item 11). - Completion and index:
gix-packre-reads the spool with a lookup that answers only from the prefetched bases. For each REF_DELTA whose base the lookup returns, gix inserts the base as a full object just before that delta and turns the delta into an OFS_DELTA. So every completion base comes before the entries that use it (S1's base-first rule); placing all of them in front of the received entries, as S1 first proposed, is not needed. A REF_DELTA the lookup has no answer for stays a REF_DELTA and gix resolves it inside the pack, in either direction: gix accepts a base that comes later in the pack. gix then rewrites the header count, re-hashes the pack (about 1 GB/s), and resolves every delta in parallel. That computes each object ID, again with SHA-1 collision detection, and writes the.idx. A REF_DELTA whose base is in neitherdepsnor the pack fails the push withunpack error. Every push takes this path: for a pack that needs no bases it costs 4% more CPU and returns the same bytes (S4). - No object twice. gix asks the lookup about every REF_DELTA base it
did not insert itself, without checking whether the pack already holds
that object. So when a pack carries object X plus a REF_DELTA against
X, and
depsalso has X, completion stores X twice. S4 reproduced this (dup.sh): a full-history resend written all-REF_DELTA, as libgit2 writes deltas, came out with 92 entries for 90 objects, andgit index-pack --strictrejected it. The rule: after indexing, one linear scan of the sorted.idxlooks for an ID that appears twice. If any does, completion runs again over the spool with a lookup that answers "not found" for those IDs, so their deltas resolve against the copy the client sent, and the scan runs again. On the same pack that gives 90 unique objects, accepted by--strict. Filtering the lookup during pass 1 is not enough on its own: pass 1 can hash full objects but not deltified ones, so a deltified X would still be inserted twice. The scan is needed anyway as the guarantee, and the rerun is a rare path (a client resending objects the server has, with REF_DELTA against them). - A pack that itself holds an object twice is rejected. If the scan
after the rerun still finds a repeated ID, the client sent both
copies, and no lookup answer can remove either. The push fails with
unpack errornaming the duplicated object, and nopacksrow is written. This is a deliberate difference from stockgit receive-pack, which accepts such a pack unless--strict(orreceive.fsckObjects) is on. No tested client sends one (S1, S4), and storing it would break the no-duplicates invariant the stitcher relies on. - Forward bases in the received entries are repaired, never
rejected (S1). A client's own pack may put a REF_DELTA before the
entry it is based on; stock
git receive-packaccepts that, so rejecting it would break pushes that work everywhere else. gix stores such a pack as it came, with the delta before its base, which would later trip the fetch path's forward-baseERRand its page. So the repair runs after completion, the duplicate check and the.idx, on gix's output:- Detection. Only an entry that is still a REF_DELTA can point
forward (OFS_DELTA distances always point back). The skip decision
uses the final completion run: if its lookup answered every
candidate (no prefetch miss, and no ID refused by a duplicate
rerun), no REF_DELTA remains and the check is skipped; this is every
stock
git push, which sends OFS_DELTA. A duplicate rerun refuses IDs on purpose, so the REF_DELTAs against them stay and can point forward; after a rerun the check always runs. Otherwise a header walk of the completed pack (entry boundaries from the sorted.idxoffsets, no inflation) compares each remaining REF_DELTA's offset with its base's offset from the.idx. - Repair. If a base comes later, the pack is rewritten on local
disk: entries keep their order, except that each entry whose base
comes later (and, transitively, any entry based on it) moves to just
after its base. Compressed data is copied unchanged; OFS_DELTA
headers whose distance changed, and the CRC32 of each changed entry,
are recomputed. The new pack is re-hashed (about 1 GB/s) and its
.idxrebuilt from the known IDs, without resolving deltas again. S1 found no client that sends forward bases: git 2.30–2.56, JGit, libgit2 and go-git all put every base first in 32 pushes (push_order.py). So the repair is a rare path that has to be correct, not a fast one. Neither the header walk nor the rewrite has been measured.
- Detection. Only an entry that is still a REF_DELTA can point
forward (OFS_DELTA distances always point back). The skip decision
uses the final completion run: if its lookup answered every
candidate (no prefetch miss, and no ID refused by a duplicate
rerun), no REF_DELTA remains and the check is skipped; this is every
stock
- Connectivity check, as
git receive-packdoes. Only objects that the incoming pack references (commit parents and trees, tree entries, tag targets) but does not contain are checked. Each must exist in the push's dependency closure, found with the same base-object lookup, batched. Nothing reachable from those objects is walked: every committed ref version is connected (seeref_versions), so existence implies connectivity. A missing object fails the push. - Pass 2 (upload): a multi-ref push may store more than one pack:
B1 places each write's objects in trunk's pack, its own pack or one
shared pack (decisions log, "where a write's objects are stored").
For each stored pack, one sequential read feeds a multipart upload, and
the
.idxand the entry table (fetch path step 4) are uploaded beside it. The trailer of each stored pack becomes itspacks.checksum. Each pack is registered before its upload, which goes to the keys of the returned row (decisions log, C1 pack generations). - Only once the pack and
.idxare durable may step 4 run. Everypacksrow is self-contained, for every reader, with no flag.
The trunk version is searched because its
.havetips are trunk ancestors; the scoped branch because the client built its pack against it too. The per-push probe cost bound is in bench/spikes/s2/RESULT.md.Thin packs stay accepted on the wire;
no-thinis an optional per-repository advertisement (S2).Costs that S4 measured on Linux, a 6.55 GB pack with 12.0 M objects (bench/spikes/s4/README.md):
- CPU per push, the whole pipeline: pass 1 (64 CPU-seconds with the
spool) plus completion and index over the spool (282), about 346
CPU-seconds, against 680 for
git index-pack, which does both in one process. The duplicate scan is one pass over the.idx. Each rare path adds its own cost: a duplicate rerun repeats the 282, and a forward-base repair adds a local copy and a re-hash. Prefetch I/O and the upload come on top and are measured by B1. - Memory: about 140 bytes of heap per object (1.6 GiB), plus the mmapped pack, which is reclaimable page cache.
- Disk: about twice the push, plus its
.idx. - Stored size: completion grows packs most for small pushes, from
320 B to 37 kB for one commit and from 10.5 MB to 57 MB for 100
commits. Git's
--fix-thingrows them the same way.
B1 measures push latency end to end, against a real bucket.
Test obligations for step 2. A2 covers each with a unit test on constructed packs, and B1 covers each end to end through
git pushand the libgit2 client in A6's matrix. Each stored pack must index under stockgit index-packwithout--fix-thin, and no ID may repeat in its.idx(--strictalso rejects a repeat, but it checks connectivity too, so it only fits a test pack that is closed):- an all-REF_DELTA pack whose bases are in the pack and not in
deps(an ordinary libgit2 push): every candidate misses indepsand the push is accepted; - an all-REF_DELTA resend of objects
depsalready has, including a REF_DELTA against a deltified object of the pack (S4'sdup.shis the model): no duplicate is stored; - a REF_DELTA before its in-pack base, with and without that base also
in
deps: stored base-first, no duplicate; - a REF_DELTA whose base is in neither the pack nor
deps:unpack error, and nopacksrow; - a received pack that holds the same object twice:
unpack errornaming that object, and nopacksrow.
- Pass 1 (receive): the incoming pack streams to a spool file on
local disk (a real disk, never tmpfs) while
-
The commit check runs. The client never saw any branch except trunk, so (full table in RESULT.md):
- Create: accepted.
- Existing branch, zero old-oid (every update of an unadvertised
branch looks like this): Aspen runs the fast-forward check itself
against
commit_index, falling back to the ref version's packs for commits not indexed yet (see thecommit_indexrow). If ancestry can't be decided, the update gets a retryableng, never a guessed verdict. Ifnewequals the current tip, it is a no-op. - Non-fast-forward:
git push --forceis invisible to the server. Accept only with push option-o force(orforce=<ref>) or a matching-o lease=<ref>:<oid>, and only whereallow_forcepermits. - Delete: clients refuse
--delete <short-name>for unadvertised branches.--delete refs/heads/<branch>arrives with a zero old-oid, so it is unconditional: like a force, it needs-o forceandallow_force. A delete with a real old-oid (branch-scoped URL, or a lease) is a compare-and-swap delete and needs no force. Trunk cannot be deleted. - Tags never fast-forward: replacing one needs
-o force. - Exact replay: a command refused only because the ref already
moved can still be
ok. It is when the ref's current tip is the command's new value (or it is deleted, for a delete), the version just before it had the command's old value (or its lease's), and the command's own rules (lease, force signal and policy, fast-forward, tag and trunk protection), checked against that previous version, accept it: answerokand write nothing. libcurl resends a receive-pack POST when a reused connection dies before any answer, so the first copy can land with its answer lost (#132). This matches the outcome, not the request: another push that made the same move, and that this command would also have been allowed to make, counts too. Only the current version's parent counts; if the ref moved again, the parent cannot be read, or the command's rules refuse the move, the command keeps its refusal. Creates take no part: a zero old value claims nothing, so a resent create is checked like any zero-old command (a plain no-op while the ref is still at its value). - Atomic: all commands in one transaction, or all rejected.
- Rejection reasons go in the
ngline and on side-band 2, even underquiet(every non-terminal client asks for it).
Every verdict (fast-forward, lease, delete, no-op) is computed against one exact
ref_versionof the target ref, and that version is the one the step 4 compare-and-swap expects. -
One transaction inserts the
ref_version, sets therefsrow with compare-and-swap, and enqueues jobs. That makes the push read-your-own-write with O(1) metadata reads and writes. If the compare-and-swap loses because the ref moved, Aspen recomputes the step 3 verdict against the new version (a bounded number of times), or returns a retryableng. It never commits a verdict computed against a stale version. A3's and B1's tests, and the differential suite, cover two concurrent pushes to one branch.The pack's trunk dependency, recorded in
deps, is the exact trunk version that step 2's lookup and connectivity check searched. It is never re-read at commit time: where a repository allows trunk force, trunk can be rewound between step 2 and this step, and a re-read would record a version whose packs were never searched. Smart HTTP does not tell the server which trunk tip it advertised, so a pack built against a rewound tip is caught by that connectivity check, not assumed away; trunk being fast-forward-only by default only makes this rare.A forced trunk update (only with
allow_force = all) is the one exception, fixed at push time by B1 (decisions log): itstrunk_depis the newest trunk version whose tip is an ancestor of the new tip, its pack also holds the objects the new tip reaches that are only in the skipped versions' closures, and orphan history stores its full closure with no trunk dependency. The new version is then exact.
Fetch path (protocol v2)
-
ls-refshonoursref-prefix, so the server never sends a 100 MB ref list. Each instance keeps a repository's refs in memory once it has listed them, and every laterls-refsasks the store only which refs changed since (MetaStore::ref_changes, one statement overref_versions.txid), so polls by stockgit ls-remote <url> <ref>, which sends noref-prefix, cost one statement and no ref rows. A listing is never stale: each request is answered from a store read that began after it arrived (decisions log, "ls-refs snapshots"). -
fetchmaps the client's wants to ref versions throughcommit_index, and each have to a version only when that version's closure is exact and its tip is the have or an ancestor of it (fetch::HaveVersion); other haves are dropped, which only costs bytes. The planner then picks the covering packs, using the cover levels as a skip list, so the pack count grows logarithmically with the versions sent. On the have side the walk skips through chained covers (C1): when it expands a held version atseq = sthat lies inside a cover carrying a chain, every version of that ref from the cover's first tosis held (each depends on its parent, so its closure contains the earlier ones'), and the walk jumps to the cover's first version instead of stepping down one version at a time. A client current on trunk fetching a branch created N trunk pushes ago visits a handful of versions per cover level plus the uncompacted tail, not N. The want side is still linear: a full clone visits every version of its closure. B2 exportsPlanStats.visitedas a metric;PlanStats.chain_skips(how many cover chains the walk jumped through) is in the planner's API for its tests and for D2, which benchmarks the stale-branch fetch (section 4). A cover without a chain never changes what the walk treats as held (decisions log, cover-subtraction rule and "C1: cover chains"). -
Clients that set
fetch.uriprotocolsget an empty inline pack pluspackfile-uristhat point at presigned URLs or the CDN. Rules from spike S3 (bench/spikes/s3/README.md):- the advertised hash is the pack's trailing SHA-1 (
packs.checksum); - only URIs whose scheme the client listed (clients do not filter);
- always a
packfilesection, even when it is empty; - URLs are direct (no redirect), self-authorizing and honour
Range; - direct-to-bucket URIs go only to clients whose
agent=parses asgit/2.56or later. Older opted-in clients get URIs only on an endpoint with the206rule below:aspen-express, or a CDN tier declaredASPEN_CDN_PAST_END=206. A CDN tier declared416(the default) is forgit/2.56and later only. With no such endpoint they get the stitched pack. The agent string is self-reported, so this is a reliability choice, not security; - each planned pack goes out as an express or CDN URI first, then as the store's own URL, then stitched into the inline pack (decisions log, "C2: delivery order per planned pack");
- an Aspen-served blob endpoint answers a range at or past the end with
206and an empty body, not416(git ≤ 2.55 gets stuck on416); - advertise
no-ref-deltaonly when every planned pack is REF_DELTA-free (open for B2: over smart HTTP the advertisement precedes the request, so B2 sets the policy; see the decisions log); - soft cap on URIs per response, with a stitched pack beyond it.
- the advertised hash is the pack's trailing SHA-1 (
-
Every other client gets a stitched pack, built from the selected packs' entries in plan order:
- drop every entry whose object id was already emitted (keep the first copy);
- copy every other entry's compressed data unchanged; rewrite only the OFS_DELTA base distance, against wherever the base copy was emitted;
- write the header object count (known from the plan before the first byte) and recompute the trailing SHA-1.
Invariants of every stitched pack, enforced by A2 and its fuzz target: each object id at most once, every delta's base earlier in the pack, and never thin (stored packs are self-contained, and go-git never asks for
thin-pack). A2 also measures the maximum delta chain depth of stitched packs: keep-first can hang a delta in a later segment off an earlier segment's copy of its base that is itself a delta, so depth can approach the sum of the segments' depths. A2 checks that git 2.30index-packaccepts the worst case the compaction levels allow. Spike S1 (bench/spikes/s1/) showed why: plain concatenation is accepted by every client only when no object repeats. Overlapping covers and completed push bases do repeat objects, and then libgit2 rejects the pack, git 2.30–2.47 reject it when the repeated object is a REF_DELTA base, every git rejects it withfsckObjects, and go-git writes a pack stock git can't read. Naive deduplication can make delta cycles that exhaust the client's memory. With base-first stored packs (packsinvariant), keep-first deduplication emits every base before its deltas in a single pass.
Lines 1–500 of 1278. Load more
# Aspen: execution plan Aspen is a self-hostable Git server, written in Rust, built for agent-scale traffic. It follows the design OpenAI published for Pando ("Scaling Git at OpenAI", https://openai.com/index/scaling-git-at-openai/, 2026-11-02): - packs are stored in object storage without being unpacked; a thin pushed pack is first completed into a self-contained pack (spike S3); - branch history lives in a database, and each branch updates independently; - a push sees only the trunk branch; - fetches are served by selecting stored packs, never by building one per client; - small packs are merged in the background in levels, like an LSM tree. It also takes inspiration from Cursor's "Git at any scale" (https://cursor.com/blog/git-at-any-scale, August 2026): push throughput is measured against Cursor's published numbers, counting a push only once it is durable, and group commit batches the write that publishes pushes the way Cursor's design does (decisions log, "Group commit"). README.md, "Where the design comes from", says which idea came from which source. Aspen is open source under the Apache License 2.0 and published at https://github.com/reason-machines/aspen by a regular sync from the maintainers' development repository. Write everything (code, docs, commit history) as if strangers will read it. This file is the source of truth for scope and sequencing. Update it when a spike or a review changes a decision, and record why. Owner: **Aspen**, a coding agent that coordinates the work: it keeps this plan, reviews and merges every pull request, and runs each node in its own agent session (the "Agent session" column below). Requested by the maintainers, 2026-11-02. ## Progress The coordinator updates this table after every merge. States: `ready`, `in progress`, `in review`, `merged`, `blocked`. Node IDs are defined in section 3. | Node | State | PR | Agent session | |---|---|---|---| | N0 Bootstrap: workspace + fast CI | merged | #1; follow-up #191 (`39c5ff2`, a CI run) fixes #189 (the required interop smoke failed whenever Launchpad's API was down; it now installs the latest Git without that API and fails unless the Git it tests is the latest release); follow-up #235 (`a45ec4a`, a CI run) fixes #72 and #207 (git's automatic maintenance repacked a test repository while `git fsck` read it; every test git client now turns it off), follow-up issue #244; follow-up #237 (`a010b3d`, a CI run) fixes #224 (rustdoc reported broken and private intra-doc links; required CI's `clippy + rustdoc + layering` job now denies rustdoc warnings) | `41f332f7` | | S1 Spike: stitched packs vs clients | merged | #12 (`2a59c6c`) | `6cd863cd` | | S2 Spike: trunk-only push semantics | merged | #4 (`7efaf66`) | `7e0672a1` | | S3 Spike: `packfile-uri` across clients | merged | #6 (`0030de3`) | `9097be06` | | S4 Spike: streaming `gix-pack` ingest | merged | #15 (`7fa04a5`) | `318cc074` | | S5 Spike: shallow and filter requests | merged | #3 (`f784cb6`) | `8c302a46` | | N1 Contracts in `aspen-core` plus schema v1, revised from the spikes | merged | #18 (`210a5b4`) | `2b241aaf` | | A1 `aspen-proto` | merged | #22 (`e365b60`) | `441ded5f` | | A1b v0 upload-pack in `aspen-proto` | merged | #33 (`93d13e2`) | `52dc423a` | | A2 `aspen-pack`: ingest, index, commit extraction, stitcher | merged | #21 (`c7e673b`) | `5178c9ef` | | A3 `aspen-meta`: Postgres + SQLite, compare-and-swap, commit index, jobs | merged | #24 (`224c75a`) | `29ac2bb8` | | A4 `aspen-blob` | merged | #20 (`92b1ac9`) | `2b550ea5` | | A5 `aspen-planner` + property tests against a `git rev-list --objects` oracle | merged | #23 (`7744ba7`) | `d5146d0d` | | A6 `testkit`: differential oracle, client matrix images | merged | #9 (`a118bec`) | `999a339b` | | A7 `bench/`: `aspen-bench` load generator plus baselines | merged | #7 (`371fda7`) | `66f280fa` | | A8 `deploy/compose`, plus Terraform AWS infrastructure | merged | #5 (`9255b3a`), follow-up #10 (`ddd64b6`) | `8091b082` | | A9 `demo/place` front end against a mock API | merged | #8 (`c1b7fb3`) | `404e7c60` | | B1 receive-pack service; measures push latency with thin-pack completion | merged | #37 (`cd8dd69`) | `83c51478` | | B2 upload-pack v2: ls-refs, planning, `packfile-uri` + stitched fallback | merged | #34 (`fb6ac9e`) | `0a93a6ec` | | B3 Auth, multi-repo, quotas, rate limits | merged | #31 (`ef1ef85`) | `d8e15304` | | C1 Compaction worker + skip-list planning | merged | #73 (`84da3dd`) | `bcb02b90` | | C2 `aspen-express` + CDN mode | merged | #46 (`ba8fceb`) | `d574ec88` | | C3 SSH transport | merged | #52 (`f1277a1`) | `ba35b787` | | C4 Mirror mode | merged | #53 (`c4656f6`) | `b38b1af1` | | C5 Observability: OTel, Prometheus, Grafana JSON; dead-job and wider-trunk-version metrics; `aspen doctor jobs` | merged | #59 (`019e296`) | `52db382b` | | C6 Helm; Terraform for GCP, Azure and Hetzner+MinIO; release pipeline | merged | #51 (`14397ef`) | `5da83d44` | | C7 Demo back end: `place` repo rules, merger bot, live event feed | merged | #54 (`51109f2`) | `c6fbe5f1` | | C8 Exact cached shallow packs | merged | #104 (`6578ec9`), follow-ups #107, #108, #109, #110 | `d6e77ec2` | | D1 Chaos suite | merged | #128 (`5b495b2`); nightly a CI run on `5b495b2`: every scenario passed (compaction kill fired 33 times in `compaction`, 3 in `mixed`); follow-up #153; follow-up #169 (`0056381`, a CI run) fixes #132 (a push libcurl resent after a connection reset was reported rejected although it landed); follow-up #176 (`ec16b61`, a CI run) fixes #131 (a transient store error left a compaction job leased to an idle worker), and the same gap in mirror mode's worker is #188; #203 (chaos `mixed` stored 60 to 120 times the repository in the bucket, about 4.5 GB an hour) fixed in #231 (`be53c39`, a CI run) (a plain-URL branch push stores only what the branch lacks, and superseded packs are reclaimed within hours); #225 (a continuous 24-hour run did not fit the 63 GB host) stays open until a continuous 24-hour run confirms disk use; the launch gate's 24-hour `mixed` run awaits a host and budget from the maintainers | `66cce8c3` | | D2 Benchmark campaign + write-up | merged | #148 (`8b6b972`); first campaign in `bench/campaign/` (one 4-vCPU machine, server and clients together, memory resized from 8.35 GB to 16.94 GB partway through); nightly `bench` now runs `stale-branch` on every target and the >2 GiB `.idx` test on `local`; findings #149 (push latency grows with trunk history) and #150 (fetch storms exhaust the metadata pool); #149 fixed in #201 (`0d86f87`, a CI run passed) (a push's search packs are computed without walking trunk history); #150 fixed in #212 (`ee0ded5`, a CI run passed) (store operations queue for the metadata pool, so fetch storms wait instead of failing); follow-ups #156 and #157, fixed in #166 (`b738d3e`) and #167 (`ea68648`); launch-post latency numbers await a rerun on identical instances with separate client machines | `d6128e95` | | D3 Fuzzing in nightly CI | merged | #130 (`2e2c2cb`); nightly `fuzz.yml` runs the 9 targets; #151 (ingest refused some valid thin pushes) fixed in #159 (`26c8c64`), its six inputs in `fuzz/regressions/pack_ingest`; #164 (property test for thin-pack completion shifts against `git index-pack --fix-thin`) fixed in #180 (`68288d6`, a CI run); #162 (ingest stored a delta with fewer than 4 bytes of data, which git refuses) fixed in #174 (`5c0d243`); #163 (two D1 chaos tests flaky under machine load) fixed in #168 (`721f409`); #129 (no test fed a known SHA-1 collision attack through the hasher) fixed in #213 (`88aff5b`, a CI run); follow-up issue #214; #139 (the HTTP and SSH front ends had no fuzz targets) fixed in #228 (`632c116`, a CI run passed), which adds four targets, so nightly `fuzz.yml` runs 13; follow-up #230 (the `jwt` target does not sign RS256/384/512 tokens) is open; launch gate: 24 h total fuzzing, at least 2.5 h per target, clean | `8a83f139` | | D4 Security review of the public endpoint | merged | #115 (`cf6fa05`); security follow-up issues #118-#125 and #137, of which #118, #120, #124 and #125 must land before the demo goes public; follow-up #192 (`ab148c5`, a CI run) fixes #122 (every workflow action is pinned to a commit SHA and downloaded tools to a SHA-256, enforced in required CI by `cargo xtask check-workflows`); follow-up #196 (`888d884`, a CI run) fixes #123 (express cache fills are capped in number and reserve their pack's size against the cache budget before copying); follow-up #194 (`1820597`, a CI run) fixes #120 (SSH client addresses reach Aspen through the AWS NLB, which preserves client IPs, and the Helm SSH Service, with `externalTrafficPolicy: Local`); follow-up #199 (`7ec2a01`, a CI run) fixes #121 (`X-Forwarded-For` is read only when the TCP peer is a configured trusted proxy and is parsed strictly, so a client no longer chooses its failed-authentication bucket; follow-up issue #202); #137 (`access_log`'s doc comment was attached to `idle_bodies`) fixed in #223 (`ca69f1e`, a CI run); follow-up #219 (`5ae82cd`, a CI run) fixes #124 (`place`'s event streams are capped per client address and in all, and swarm starts are atomic); follow-up #210 (`d5d37fb`, a CI run) fixes #125 (every deploy path sets per-caller read and push rates by default, images are pinned by digest and checked in required CI, a secret file other users may read stops startup, and the Helm chart can limit its metrics port to listed peers with a NetworkPolicy); follow-up #236 (`e199b5f`, a CI run) fixes #198 (concurrent failed authentications overshot the limit; a presented credential is now charged before it is checked and refunded when it authenticates), follow-up issues #242 and #243 | `0c9d566a` | | D5 Public deployment | merged | #112 (`944822b`); not deployed: awaits isolated AWS account, subdomain, budget-alert emails, spend approval from the maintainers; public SSH was blocked on #120, fixed in #194 (`1820597`), and the demo still defaults to `enable_ssh = false`; follow-ups #140, #141, #142, #144, #145 and #146; #140 (the place server's shutdown drain left connections open) fixed in #187 (`8e28b4d`, a CI run); #144 (`create-database.sh` passed the place password in psql's argv) fixed in #205 (`c46e4c5`, a CI run), which reads it with psql's `\getenv`; follow-up issue #208; #145 (`smoke.sh` passed the token in git's argv) fixed in #209 (`6233148`, a CI run), which passes it through the environment and requires git 2.31 or later; #141 (no test for the place server's shutdown error path or its no-connections fast path) fixed in #216 (`049dec8`, a CI run); #142 (the public-demo runbook's SSH known limit predated #115) fixed in #220 (`21a1e33`, a CI run); #146 (the place web server panicked on a page/API route collision) fixed in #222 (`e646656`), which refuses a page file at a reserved path; its own main run was cancelled as superseded, and a CI run on `ca69f1e` covers it | `20d32199` | | D6 Docs site, "run it yourself in 5 minutes", launch post draft | merged | #114 (`ab12b95`), follow-up #117 (`78799f3`); follow-up issue #116 | `d9f9138a` | | L0 Differential suite and client matrix against a real Aspen | merged | #136 (`b85fd62`); #134 (receive-pack refused JGit's gzip push bodies with `415`) fixed in #175 (`643696d`, a CI run), so the nightly JGit-against-Aspen client-matrix row gates again; nightly `differential-long` gains an `aspen, postgres` row; follow-up #172 (`afb2e88`) fixes #154 and #161 (nightly all-tests ran no ignored tests past `aspen-pack`) and #155 (`git-latest` matrix row; verified and closed on nightly a CI run, main `4213a6a`, passed); follow-up #170 (`96611bd`, a CI run) fixes #133 (delete of a missing ref), the oracle's last known divergence; follow-ups #178 (`8b31f3a`) and #184 (`4213a6a`) fix #177 and #183 (nightly all-tests ran out of disk in the over-2-GiB `.idx` test), verified by nightly a CI run; follow-up #173 (`48849cf`, a CI run) fixes #135 (the oracle also pushes and races through the plain repository URL) | `276317e2` | --- ## 1. Product "A Git server that stays fast when a thousand agents push at once, and that you can run yourself on any cloud in five minutes." Target users: teams whose agents, CI and humans hammer one repository. They run Aspen in one of two ways: - **Primary mode:** Aspen is the Git host. - **Mirror mode:** Aspen sits in front of GitHub or GitLab. Fetches come from Aspen's own packs, and pushes are forwarded upstream either synchronously (proxy) or in the background (Aspen is primary, upstream is a downstream mirror). Mirror mode is the main adoption path, because nobody has to migrate. ### Goals - Works with stock `git`, over HTTP and SSH, with no client changes required. - Pushes to different branches scale horizontally. The only contended write is a compare-and-swap on one branch's tip. - Reads scale to the network limit. Clients that set `git config fetch.uriprotocols https` download packs straight from object storage or a CDN. - Runs anywhere: one binary, Postgres, and any S3-compatible bucket (S3, GCS, Azure Blob, R2, MinIO, or the local filesystem). - Reliability is proven by differential testing against stock Git, chaos testing and fuzzing, not asserted. ### Not in v1 (document each one, and give a clear error where needed) - Partial-clone filters: don't advertise `filter`, so clients fall back to a full fetch (verified in S5). Lazy fetches from existing partial clones (`want <blob-oid>`) get a clear `ERR`; mirror mode may forward them upstream later. - Exact shallow packs for every deepen form: a plain `--depth=N` fetch of a ref version's tip (N up to 64) is served an exact pack, built once per version and depth (node C8). `--deepen` (relative), `--shallow-since`, `--shallow-exclude`, `--unshallow`, deeper depths and non-tip wants are still served the full history of the wants plus an exact `shallow-info` (S5). Operators can set `shallow = refuse` to answer with a clear `ERR` instead, or `ASPEN_SHALLOW_PACKS=false` to serve every shallow fetch the full history. - SHA-256 repositories. - A web UI for browsing code. - Code review or issues. --- ## 2. Architecture ``` git client ─HTTP/SSH─► aspen-server ─CAS per ref─► Postgres │ (refs, ref_versions, commit_index, covers) │ stream+validate ▼ object store (S3/GCS/Azure/R2/MinIO/fs) ◄── aspen-compactor (L0→L4 covers) ▲ git client ◄─packfile-uri── aspen-express (regional pack cache) / CDN ``` ### Data model (schema v1, N1) The tables below are schema v1, in [crates/aspen-meta/migrations](../crates/aspen-meta/migrations) (Postgres and SQLite, kept identical) and described column by column in [crates/aspen-meta/SCHEMA.md](../crates/aspen-meta/SCHEMA.md). The Rust contracts are in `crates/aspen-core`. Object IDs are 20-byte binary, timestamps are microseconds since the epoch, and every cross-table reference inside a repository is a composite foreign key through `repo_id`. | Table | Holds | |---|---| | `repos` | Repository settings, including trunk ref, force policy (`allow_force`: `none`, `branches` (default; never trunk) or `all`; a forced trunk update keeps trunk exact under B1's forced-trunk rule, decisions log) and writable ref namespaces (default `refs/heads/*`, `refs/tags/*`) | | `refs` | `(repo, name) → current ref_version_id, seq`; this is the only compare-and-swap target, and its foreign key on `(version, repo, name, seq)` plus `SET seq = seq + 1` lets a swap land only on the expected version's child. A deleted ref keeps its row, pointing at the deletion version | | `ref_versions` | `id, repo, ref, seq, parent_version, tip_oid, closure_exact, pack_id, trunk_dep, scoped_dep, created_at`. `closure_exact` says whether the version's closure is exactly what its tip reaches, which decides whether a fetch have may stand for it (`fetch::HaveVersion`); it is computed per write against the pack that write's version names, which B1's placement rule chooses (decisions log, "where a write's objects are stored"). `seq` numbers a ref's versions from 1, and `parent_version` is the version with `seq - 1`. `tip_oid` null is a deletion; `pack_id` null means the push stored no pack. The deps are typed: `trunk_dep` is the exact trunk version the push's lookup and connectivity check searched (push path, step 4), `scoped_dep` the scoped ref's old version for a scoped-URL push (S2), and for a client's push of one branch through the plain repository URL that is not forced or leased, that branch's version the push searched, so it stores only what the branch's history lacks (issue #203). Parent and deps have smaller IDs, so the graph is acyclic. Invariant: every committed ref version is **connected**: every object reachable from `tip_oid` exists in its pack or its deps' closure (enforced by the push connectivity check) | | `packs` | `id, repo, checksum, generation, pack_key, size, idx_key, idx_size, entries_key, entries_size, object_count, ref_delta_count, level, origin, received_bytes`. Keys are `repos/<repo>/packs/<checksum>.{pack,idx,entries}` for generation 0 and `<checksum>.g<n>.{pack,idx,entries}` for generation n, the next registration of a checksum whose earlier row garbage collection swept; `checksum` is unique per repository among live rows, and readers always take keys from the row, never from the checksum alone. `received_bytes` (push packs only) against `size` shows completion growth (S4); `ref_delta_count` gates `no-ref-delta` (S3). Writers **register, then upload, then reference**: `register_pack` returns the row (and so its generation and keys), the pack, `.idx` and entry table are uploaded to those keys, and only then may a version or cover name the row; so every row a version or cover names can be served, while an unnamed row may lack its blobs (decisions log, C1 pack generations). Invariant: every stored pack is **self-contained**: no delta base lies outside the pack (thin pushes are completed at ingest, S3; compaction writes self-contained packs). So any selected pack can be served alone: as a packfile URI, which the client indexes on its own, or as one segment of a stitched pack. Second invariant: every stored pack is **base-first**: each delta's base entry comes before the delta (S1; ingest places each completion base before the first delta that uses it and moves any received delta that precedes its base to just after it, and compaction must write base-first too). With both, the stitcher deduplicates in one streaming pass. A pack is *not* closed under reachability: objects its trees and commits reference may live in its `deps` closure | | `commit_index` | `(repo, commit_oid) → ref_version_id, parent oids (concatenated 20-byte IDs), committer time`, written synchronously for the tip and asynchronously for the rest of a large push. `ref_version_id` is a version whose tip reaches the commit: it resolves a want, never a have. Parents and time serve `shallow-info` and fast-forward checks without reading packs (S5). A walk that reaches a commit with no row yet reads that commit from the packs of the ref version it came from (`pack_id`, then `deps`), which hold every ancestor of its tip; a missing row is never read as "no parents" or "not an ancestor". If the commit is in neither place, the request fails with a retryable error | | `covers` | `repo, ref, level (1–4), first_seq, last_seq, pack_id or null` (null until compacted, and then a compaction pack at the cover's level): a range of one ref's version chain, aligned by `CoverLayout` (level k spans `16^k` versions). Migration 0005 adds `cover_chains`: a cover whose range is a **dependency chain** (every version after the first depends on its parent) records the ID of its first version, which a foreign key ties to the cover's ref and `first_seq`. Covers follow a ref's version history, closures follow dependencies, so a fetch treats a cover's range as held for a have only when it carries a chain (decisions log, "C1: cover chains") | | `shallow_packs` | Migration 0006 (node C8): `repo, version, depth → pack_id, recorded_at_us`, the **exact shallow pack** of a ref version at a depth: exactly the commits fewer than `depth` parents below the version's tip (the *region*), the tags the tip peels through, and every tree and blob the region's trees reach. The pack is an ordinary `packs` row (registered as a compaction pack at level 1 that no cover names) and obeys every stored-pack invariant. The first recording wins; the sweep counts a recorded pack as named; garbage collection evicts recordings of versions that are no longer current after a TTL (decisions log, C8 rows). Migration 0007 adds `used_at_us`, a recording's last use: garbage collection also evicts recordings unused for a day, current or not, and each repository's recorded packs stay under a byte cap, least recently used evicted first (issue #108) | | `jobs` | Background work queue (`index_commits`, `compact`, `mirror_push` on a ref version; `rewrite_pack` on a pack that broke base-first), written in the push's own transaction and enqueued idempotently (one job that is not done per kind and target). States `queued`, `running` (leased, fenced on worker and attempt), `done`, `dead` (after 10 attempts, failed or expired). A dead job is requeued by resolving it and enqueueing a fresh job in one transaction, never by resetting `attempts` | ### Push path Confirmed against git 2.30, 2.39, 2.56 and OpenAI's fork by spike S2. The full rule table and the decisive wire excerpts are in [bench/spikes/s2/RESULT.md](../bench/spikes/s2/RESULT.md). 1. The client connects and Aspen runs v0/v1 receive-pack. It advertises trunk as the only named ref, plus earlier trunk tips as `.have` lines, or `capabilities^{}` for an empty repository: every one of the newest `8 × have_tips` trunk versions (64 by default), then tips about a quarter apart down to the oldest trunk version a push searches (`meta::have_tip_seqs`; at most 108 lines for a million versions). Without the `.have` lines, a client that has not fetched the latest trunk pushes its whole history; with them, a client any number of landings behind resends at most about a quarter of them (decisions log, "item 10"). Capabilities: `report-status delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent`. - A **branch-scoped URL**, `/<repo>.git/-/refs/heads/<branch>`, also advertises that one ref. It is the opt-in route for stock `--force-with-lease` and `git push --delete <branch>`. - **`push.negotiate`.** Through the plain URL, stock Git resends every commit of the pushed branch that is not on trunk. With `git config push.negotiate true` it first runs a negotiation-only v2 fetch (`wait-for-done`, haves, no wants); Aspen advertises `wait-for-done` and acknowledges every have with a `commit_index` row, and Git leaves those commits out. A fast-forward of the pushed branch then needs nothing beyond its usual dependencies (issue #203's branch dependency). A push whose left-out commits are outside its dependencies (another branch's commits) is completed from their versions (decisions log, "push.negotiate"). 2. Ingest produces a **self-contained** pack, which packfile URIs require (spike S3), and never unpacks it. It is synchronous and takes two passes: - **Pass 1 (receive):** the incoming pack streams to a spool file on local disk (a real disk, never tmpfs) while `gix-pack` parses it. It inflates every entry, checks each entry's CRC32, and checks the client's trailer with SHA-1 collision detection. Memory stays under 10 MiB at any pack size; the parse runs at about 110 MB/s per core (S4). The base IDs of all REF_DELTA entries are collected as **candidate bases**. A candidate may be a thin base (in `deps`) or an object in the same pack: libgit2 sends every delta as REF_DELTA, mostly against bases in the pack (S1's `push_order.py`), and pass 1 cannot tell the two apart, because a deltified entry's ID is known only once it is resolved. A push whose header object count exceeds the limit is refused before its body is read. - **Base prefetch:** each candidate, with its delta chain, is looked up in parallel before completion starts. The base-object lookup probes the `.idx` of the covering packs of every ref version advertised on the connection: trunk, plus the branch for a branch-scoped URL, which are exactly the push's `deps` (see [S3's README](../bench/spikes/s3/README.md#base-object-lookup)). A candidate that `deps` doesn't have is **not an error**: it is expected to be in the pack. Prefetching matters because gix asks for bases one at a time from inside its parse. A lookup that went to the blob store there would serialize thousands of round trips per push: S4 counted 5,431 entry reads for a 100-commit Linux push. The prefetch is designed, not measured: S4's harness used a synchronous lookup over a local pack, and B1 measures the prefetch against a real bucket. Every stored pack's delta chains are known before they are read (chain maps from its entry table: in memory beside a whole `.idx`, on the spool disk beside a sampled one), so all the entries of every candidate's chain are read in one round of parallel range reads, whatever the chain's depth (decisions log, item 11). - **Completion and index:** `gix-pack` re-reads the spool with a lookup that answers only from the prefetched bases. For each REF_DELTA whose base the lookup returns, gix inserts the base as a full object just before that delta and turns the delta into an OFS_DELTA. So every completion base comes before the entries that use it (S1's base-first rule); placing all of them in front of the received entries, as S1 first proposed, is not needed. A REF_DELTA the lookup has no answer for stays a REF_DELTA and gix resolves it inside the pack, in either direction: gix accepts a base that comes later in the pack. gix then rewrites the header count, re-hashes the pack (about 1 GB/s), and resolves every delta in parallel. That computes each object ID, again with SHA-1 collision detection, and writes the `.idx`. A REF_DELTA whose base is in neither `deps` nor the pack fails the push with `unpack error`. Every push takes this path: for a pack that needs no bases it costs 4% more CPU and returns the same bytes (S4). - **No object twice.** gix asks the lookup about every REF_DELTA base it did not insert itself, without checking whether the pack already holds that object. So when a pack carries object X plus a REF_DELTA against X, and `deps` also has X, completion stores X twice. S4 reproduced this ([`dup.sh`](../bench/spikes/s4/dup.sh)): a full-history resend written all-REF_DELTA, as libgit2 writes deltas, came out with 92 entries for 90 objects, and `git index-pack --strict` rejected it. The rule: after indexing, one linear scan of the sorted `.idx` looks for an ID that appears twice. If any does, completion runs again over the spool with a lookup that answers "not found" for those IDs, so their deltas resolve against the copy the client sent, and the scan runs again. On the same pack that gives 90 unique objects, accepted by `--strict`. Filtering the lookup during pass 1 is not enough on its own: pass 1 can hash full objects but not deltified ones, so a deltified X would still be inserted twice. The scan is needed anyway as the guarantee, and the rerun is a rare path (a client resending objects the server has, with REF_DELTA against them). - **A pack that itself holds an object twice is rejected.** If the scan after the rerun still finds a repeated ID, the client sent both copies, and no lookup answer can remove either. The push fails with `unpack error` naming the duplicated object, and no `packs` row is written. This is a deliberate difference from stock `git receive-pack`, which accepts such a pack unless `--strict` (or `receive.fsckObjects`) is on. No tested client sends one (S1, S4), and storing it would break the no-duplicates invariant the stitcher relies on. - **Forward bases in the received entries are repaired, never rejected** (S1). A client's own pack may put a REF_DELTA before the entry it is based on; stock `git receive-pack` accepts that, so rejecting it would break pushes that work everywhere else. gix stores such a pack as it came, with the delta before its base, which would later trip the fetch path's forward-base `ERR` and its page. So the repair runs **after** completion, the duplicate check and the `.idx`, on gix's output: - *Detection.* Only an entry that is still a REF_DELTA can point forward (OFS_DELTA distances always point back). The skip decision uses the **final** completion run: if its lookup answered every candidate (no prefetch miss, and no ID refused by a duplicate rerun), no REF_DELTA remains and the check is skipped; this is every stock `git push`, which sends OFS_DELTA. A duplicate rerun refuses IDs on purpose, so the REF_DELTAs against them stay and can point forward; after a rerun the check always runs. Otherwise a header walk of the completed pack (entry boundaries from the sorted `.idx` offsets, no inflation) compares each remaining REF_DELTA's offset with its base's offset from the `.idx`. - *Repair.* If a base comes later, the pack is rewritten on local disk: entries keep their order, except that each entry whose base comes later (and, transitively, any entry based on it) moves to just after its base. Compressed data is copied unchanged; OFS_DELTA headers whose distance changed, and the CRC32 of each changed entry, are recomputed. The new pack is re-hashed (about 1 GB/s) and its `.idx` rebuilt from the known IDs, without resolving deltas again. S1 found no client that sends forward bases: git 2.30–2.56, JGit, libgit2 and go-git all put every base first in 32 pushes ([`push_order.py`](../bench/spikes/s1/push_order.py)). So the repair is a rare path that has to be correct, not a fast one. Neither the header walk nor the rewrite has been measured. - **Connectivity check**, as `git receive-pack` does. Only objects that the incoming pack references (commit parents and trees, tree entries, tag targets) but does not contain are checked. Each must exist in the push's dependency closure, found with the same base-object lookup, batched. Nothing reachable from those objects is walked: every committed ref version is connected (see `ref_versions`), so existence implies connectivity. A missing object fails the push. - **Pass 2 (upload):** a multi-ref push may store more than one pack: B1 places each write's objects in trunk's pack, its own pack or one shared pack (decisions log, "where a write's objects are stored"). For each stored pack, one sequential read feeds a multipart upload, and the `.idx` and the entry table (fetch path step 4) are uploaded beside it. The trailer of each stored pack becomes its `packs.checksum`. Each pack is registered before its upload, which goes to the keys of the returned row (decisions log, C1 pack generations). - Only once the pack and `.idx` are durable may step 4 run. Every `packs` row is self-contained, for every reader, with no flag. The trunk version is searched because its `.have` tips are trunk ancestors; the scoped branch because the client built its pack against it too. The per-push probe cost bound is in [bench/spikes/s2/RESULT.md](../bench/spikes/s2/RESULT.md#for-n1). Thin packs stay accepted on the wire; `no-thin` is an optional per-repository advertisement (S2). Costs that S4 measured on Linux, a 6.55 GB pack with 12.0 M objects ([bench/spikes/s4/README.md](../bench/spikes/s4/README.md)): - CPU per push, the whole pipeline: pass 1 (64 CPU-seconds with the spool) plus completion and index over the spool (282), about 346 CPU-seconds, against 680 for `git index-pack`, which does both in one process. The duplicate scan is one pass over the `.idx`. Each rare path adds its own cost: a duplicate rerun repeats the 282, and a forward-base repair adds a local copy and a re-hash. Prefetch I/O and the upload come on top and are measured by B1. - Memory: about 140 bytes of heap per object (1.6 GiB), plus the mmapped pack, which is reclaimable page cache. - Disk: about twice the push, plus its `.idx`. - Stored size: completion grows packs most for small pushes, from 320 B to 37 kB for one commit and from 10.5 MB to 57 MB for 100 commits. Git's `--fix-thin` grows them the same way. B1 measures push latency end to end, against a real bucket. Test obligations for step 2. A2 covers each with a unit test on constructed packs, and B1 covers each end to end through `git push` and the libgit2 client in A6's matrix. Each stored pack must index under stock `git index-pack` without `--fix-thin`, and no ID may repeat in its `.idx` (`--strict` also rejects a repeat, but it checks connectivity too, so it only fits a test pack that is closed): - an all-REF_DELTA pack whose bases are in the pack and not in `deps` (an ordinary libgit2 push): every candidate misses in `deps` and the push is accepted; - an all-REF_DELTA resend of objects `deps` already has, including a REF_DELTA against a deltified object of the pack (S4's `dup.sh` is the model): no duplicate is stored; - a REF_DELTA before its in-pack base, with and without that base also in `deps`: stored base-first, no duplicate; - a REF_DELTA whose base is in neither the pack nor `deps`: `unpack error`, and no `packs` row; - a received pack that holds the same object twice: `unpack error` naming that object, and no `packs` row. 3. The commit check runs. The client never saw any branch except trunk, so (full table in RESULT.md): - **Create:** accepted. - **Existing branch, zero old-oid** (every update of an unadvertised branch looks like this): Aspen runs the fast-forward check itself against `commit_index`, falling back to the ref version's packs for commits not indexed yet (see the `commit_index` row). If ancestry can't be decided, the update gets a retryable `ng`, never a guessed verdict. If `new` equals the current tip, it is a no-op. - **Non-fast-forward:** `git push --force` is invisible to the server. Accept only with push option `-o force` (or `force=<ref>`) or a matching `-o lease=<ref>:<oid>`, and only where `allow_force` permits. - **Delete:** clients refuse `--delete <short-name>` for unadvertised branches. `--delete refs/heads/<branch>` arrives with a zero old-oid, so it is unconditional: like a force, it needs `-o force` and `allow_force`. A delete with a real old-oid (branch-scoped URL, or a lease) is a compare-and-swap delete and needs no force. Trunk cannot be deleted. - **Tags** never fast-forward: replacing one needs `-o force`. - **Exact replay:** a command refused only because the ref already moved can still be `ok`. It is when the ref's current tip is the command's new value (or it is deleted, for a delete), the version just before it had the command's old value (or its lease's), and the command's own rules (lease, force signal and policy, fast-forward, tag and trunk protection), checked against that previous version, accept it: answer `ok` and write nothing. libcurl resends a receive-pack POST when a reused connection dies before any answer, so the first copy can land with its answer lost (#132). This matches the outcome, not the request: another push that made the same move, and that this command would also have been allowed to make, counts too. Only the current version's parent counts; if the ref moved again, the parent cannot be read, or the command's rules refuse the move, the command keeps its refusal. Creates take no part: a zero old value claims nothing, so a resent create is checked like any zero-old command (a plain no-op while the ref is still at its value). - **Atomic:** all commands in one transaction, or all rejected. - Rejection reasons go in the `ng` line and on side-band 2, even under `quiet` (every non-terminal client asks for it). Every verdict (fast-forward, lease, delete, no-op) is computed against one exact `ref_version` of the target ref, and that version is the one the step 4 compare-and-swap expects. 4. One transaction inserts the `ref_version`, sets the `refs` row with compare-and-swap, and enqueues jobs. That makes the push read-your-own-write with O(1) metadata reads and writes. If the compare-and-swap loses because the ref moved, Aspen recomputes the step 3 verdict against the new version (a bounded number of times), or returns a retryable `ng`. It never commits a verdict computed against a stale version. A3's and B1's tests, and the differential suite, cover two concurrent pushes to one branch. The pack's trunk dependency, recorded in `deps`, is the exact trunk version that step 2's lookup and connectivity check searched. It is never re-read at commit time: where a repository allows trunk force, trunk can be rewound between step 2 and this step, and a re-read would record a version whose packs were never searched. Smart HTTP does not tell the server which trunk tip it advertised, so a pack built against a rewound tip is caught by that connectivity check, not assumed away; trunk being fast-forward-only by default only makes this rare. A **forced trunk update** (only with `allow_force = all`) is the one exception, fixed at push time by B1 (decisions log): its `trunk_dep` is the newest trunk version whose tip is an ancestor of the new tip, its pack also holds the objects the new tip reaches that are only in the skipped versions' closures, and orphan history stores its full closure with no trunk dependency. The new version is then exact. ### Fetch path (protocol v2) 1. `ls-refs` honours `ref-prefix`, so the server never sends a 100 MB ref list. Each instance keeps a repository's refs in memory once it has listed them, and every later `ls-refs` asks the store only which refs changed since (`MetaStore::ref_changes`, one statement over `ref_versions.txid`), so polls by stock `git ls-remote <url> <ref>`, which sends no `ref-prefix`, cost one statement and no ref rows. A listing is never stale: each request is answered from a store read that began after it arrived (decisions log, "ls-refs snapshots"). 2. `fetch` maps the client's wants to ref versions through `commit_index`, and each have to a version only when that version's closure is exact and its tip is the have or an ancestor of it (`fetch::HaveVersion`); other haves are dropped, which only costs bytes. The planner then picks the covering packs, using the cover levels as a skip list, so the pack count grows logarithmically with the versions sent. On the have side the walk skips through chained covers (C1): when it expands a held version at `seq = s` that lies inside a cover carrying a chain, every version of that ref from the cover's first to `s` is held (each depends on its parent, so its closure contains the earlier ones'), and the walk jumps to the cover's first version instead of stepping down one version at a time. A client current on trunk fetching a branch created N trunk pushes ago visits a handful of versions per cover level plus the uncompacted tail, not N. The want side is still linear: a full clone visits every version of its closure. B2 exports `PlanStats.visited` as a metric; `PlanStats.chain_skips` (how many cover chains the walk jumped through) is in the planner's API for its tests and for D2, which benchmarks the stale-branch fetch (section 4). A cover without a chain never changes what the walk treats as held (decisions log, cover-subtraction rule and "C1: cover chains"). 3. Clients that set `fetch.uriprotocols` get an empty inline pack plus `packfile-uris` that point at presigned URLs or the CDN. Rules from spike S3 (`bench/spikes/s3/README.md`): - the advertised hash is the pack's trailing SHA-1 (`packs.checksum`); - only URIs whose scheme the client listed (clients do not filter); - always a `packfile` section, even when it is empty; - URLs are direct (no redirect), self-authorizing and honour `Range`; - direct-to-bucket URIs go only to clients whose `agent=` parses as `git/2.56` or later. Older opted-in clients get URIs only on an endpoint with the `206` rule below: `aspen-express`, or a CDN tier declared `ASPEN_CDN_PAST_END=206`. A CDN tier declared `416` (the default) is for `git/2.56` and later only. With no such endpoint they get the stitched pack. The agent string is self-reported, so this is a reliability choice, not security; - each planned pack goes out as an express or CDN URI first, then as the store's own URL, then stitched into the inline pack (decisions log, "C2: delivery order per planned pack"); - an Aspen-served blob endpoint answers a range at or past the end with `206` and an empty body, not `416` (git ≤ 2.55 gets stuck on `416`); - advertise `no-ref-delta` only when every planned pack is REF_DELTA-free (open for B2: over smart HTTP the advertisement precedes the request, so B2 sets the policy; see the decisions log); - soft cap on URIs per response, with a stitched pack beyond it. 4. Every other client gets a **stitched pack**, built from the selected packs' entries in plan order: - drop every entry whose object id was already emitted (keep the first copy); - copy every other entry's compressed data unchanged; rewrite only the OFS_DELTA base distance, against wherever the base copy was emitted; - write the header object count (known from the plan before the first byte) and recompute the trailing SHA-1. Invariants of every stitched pack, enforced by A2 and its fuzz target: each object id at most once, every delta's base earlier in the pack, and never thin (stored packs are self-contained, and go-git never asks for `thin-pack`). A2 also measures the maximum delta chain depth of stitched packs: keep-first can hang a delta in a later segment off an earlier segment's copy of its base that is itself a delta, so depth can approach the sum of the segments' depths. A2 checks that git 2.30 `index-pack` accepts the worst case the compaction levels allow. Spike S1 (`bench/spikes/s1/`) showed why: plain concatenation is accepted by every client only when no object repeats. Overlapping covers and completed push bases do repeat objects, and then libgit2 rejects the pack, git 2.30–2.47 reject it when the repeated object is a REF_DELTA base, every git rejects it with `fsckObjects`, and go-git writes a pack stock git can't read. Naive deduplication can make delta cycles that exhaust the client's memory. With base-first stored packs (`packs` invariant), keep-first deduplication emits every base before its deltas in a single pass.