# ADR-013: receive-pack (push) state machine ## Status Accepted (resolves OQ-04) ## Context OQ-04 (`deferred(unclear)`) held the receive-pack validation gap: the pieces were decided (ADR-004 ingestion via `gix-pack::data::input` with `streaming-input`; `gix-ref` transaction CAS; `gix-fsck` connectivity; POC-3: request bodies stream) but the push state machine's shape was not. The listed unknowns: capability advertisement set under the honest-advertisement invariant, request-line parsing + shallow policy, thin-pack acceptance, ingestion composition, CAS timing, status-report shape, and the http framing surface (ADR-003's V2 decision covers fetch only). Resolution method: a walkthrough against real `git push` captures (git 2.43.0 over file://, git:// daemon, and smart-http against a ground-truth mock receive-pack), raw stdio requests driven into real `git receive-pack` for policy-error ground truth, and gitoxide source verification of the ingestion composition. Captures are recorded in `docs/research/push-captures.md`. ## Decision **Receive-pack is V0-framed, by upstream design, and we serve it as such.** 1. **Version surface**: receive-pack has no version negotiation at all — `git push` never speaks V2 on the push path (observed: `protocol.v2` clients push with the identical V0/V1 shape; no `version=2` exchange exists). ADR-003's V2-first decision therefore governs fetch only; the push side is V0-framed on every door. This is upstream's shape, not a choice of ours, and it is wire-stable by the same token. 2. **Capability advertisement** (per-door, honest per ADR-003): - Smart-http GET: `# service=git-receive-pack` + flush, then a **V0-shaped ref advertisement** — capabilities ride NUL-attached on the FIRST ref line; remaining ref lines are bare; flush. The `capabilities^{}` zero-id sentinel replaces the ref lines only when the repo has no refs (git rejects a bare capability dump, and rejects a trailing sentinel when refs exist). - Duplex: the same ref advertisement without the service prefix; the server speaks first. - The served set is `report-status report-status-v2 delete-refs side-band-64k atomic ofs-delta object-format=sha1` (+ `push-options` under a config gate). **`quiet` is not advertised** (it is a client-side preference whose only server duty is suppressing band-2 progress chatter — we never send progress). `agent=alkgit/` is advertised (same convention upstream uses; value is the crate version). `shallow`/`allow-tip-sha1-in-want`-style fetch caps do not appear here. - Everything we do not serve (e.g. `push-cert`) is declined by omission. - `report-status-v2` is served: the v1 report shape (no option lines) IS valid v2 — option lines exist only when the server emits them, and v1 emits none (push-options are metadata-only in v1). Clients selecting v2 parse the same shape. - ACL runs before the first ref line (ADR-007 unchanged): private repos advertise nothing. 3. **Request parsing** (one POST / one stream): ``` command lines ( , caps NUL-attached on line 1) [shallow lines] → v1: rejected up front with a pkt-line error (see 4) flush [push-options section: bare pkt-lines + flush] (only if negotiated) PACK stream (raw, to end of body / stream) ``` - Deletes: `old= new=zero-id`; creates: `old=zero-id`. - An immediate flush (no commands) is a client-side nothing-to-do — reply flush, no report. - A pack is ALWAYS expected after the command flush (real receive-pack errors with `unpack eof …` + all-refs `unpacker error` otherwise). An empty pack (zero objects) is valid and reported as such. 4. **Shallow on push: rejected for v1** — confirming the OQ's expected resolution. `git push` from a shallow clone sends `shallow ` lines before the commands. Real receive-pack accepts them when the pack completes history and rejects with `ng shallow update not allowed` otherwise. We serve neither: shallow request lines are rejected up front with a clear pkt-line error (mirroring fetch's decline-by-omission in ADR-003, keeping depth semantics symmetric). Declining up front is cheaper than accepting packs we cannot fsck: a shallow push's pack terminates on grafted boundaries that our connectivity check cannot close, and `receive.shallowUpdateDeepen`-style semantics are a scope decision for a later ADR, not a v1 need. This is a two-way door: adding shallow support later is additive (the shallow-request capability rides upstream's existing grammar when implemented). 5. **Thin packs: accepted, bases from the server odb.** `git push` sends thin packs by default (`push.thin=true`, no capability involved): captured packs exclude objects the client believes the server has and fail `git index-pack --strict` standalone; `--fix-thin` completes them. Ingestion therefore passes the server's own `gix_object::Find` handle as `thin_pack_base_object_lookup` to `Bundle::write_to_directory` (the upstream parameter exists for exactly this); the no-lookup composition (`Option` — a lookup that can never resolve, valid only for packs known self-contained, per poc2-findings) is not used on the push path. Thin-pack base availability is enforced by the lookup: a base the server lacks fails ingestion (→ `unpack ng`), which is the correct honest outcome. 6. **Ingestion composition** (ADR-004's tool choice, now bound to the push machine): - Stream the pack body into `Bundle::write_to_directory_eagerly` (= `data::input` `streaming-input` with `LookupRefDeltaObjectsIter` when bases resolve, `EntryDataMode::KeepAndCrc32`, `Mode::Verify`), `directory` = the repo's pack dir: the outcome is a written pack + index with a `.keep` file created before the pack lands (gc-safe against the not-yet-applied refs) — gitoxide's own receive shape. - Budgets (ADR-009): max pack size counts the streamed body (413-mapped on http); wall clock and the blocking-pipeline concurrency budget apply (the generation budget covers ingestion — same `spawn_blocking` pool, amended in ADR-009's table). - Ingestion runs on `spawn_blocking` (POC-2's shape: store shared, handle per session — inside the gix impl; the wire layer's pipeline-concurrency permit wraps the trait call per ADR-009, backend.md concurrency model). - fsck/connectivity: `gix-fsck::Connectivity` over the odb handle after indexing, for each new tip reachable from the pushed refs; missing objects → `unpack ng `. - The ingest trait (`GitPackIngest`) **prepares** validated updates; the ref transaction itself is applied by `GitRefs` (single CAS home, backend.md unchanged). 7. **CAS timing: unpack-first, then per-ref checks** (the observed upstream order, adopted): parse commands → read + index + fsck the pack → per-ref validation (name, CAS, policy) → atomic rollback if selected → report. Evidence: real receive-pack reports `unpack ok` + `ng funny refname` / `deletion prohibited` / stale-old ng only AFTER unpack; a missing pack fails everything at unpack. A pre-unpack CAS pre-check is permitted as a fail-fast optimization (reject before reading the body), but the protocol-correct order is as observed and is what v1 implements. Per-ref CAS uses `gix-ref` transactions (`Change::Update/Delete` with `PreviousValue::MustExistAndMatch` / `MustNotExist`) — one transaction for the whole push, which is what makes `atomic` correct. **The v1 update policy** (the "policy" leg of the per-ref check): CAS on old-value per the request (zero-id create, current-id update/delete) and nothing more — deletes are allowed (the `delete-refs` capability is advertised) and non-fast-forward updates are allowed (force-push is the client's explicit choice via the advertised old value; the CAS contract is exact-match, and upstream's `denyNonFastForwards`-style history checks are a deployment concern, expressible later as config in the assembly layer without a trait change — the reasons observed above come from upstream's *config-dependent* policies, not protocol requirements). A reason alkgit emits itself (`funny refname`, `shallow update not allowed`, `atomic push failure`, CAS failures) needs no config. 8. **Status report**: when the client selected `side-band-64k`, the report is band-1 chunks whose payload is ITSELF pkt-line-framed: `unpack ok|ng `, per-ref `ok ` / `ng `, an INNER flush terminating the report section, then the OUTER flush. Without sideband: bare pkt-lines to one flush. The report is `unpack ok` even when some refs fail; pack-level failure is `unpack ng ` + all-refs `unpacker error`. Observed per-ref reasons from real receive-pack are forwarded verbatim (`shallow update not allowed`, `deletion prohibited`, `non-fast-forward`, `atomic push failure`, `funny refname`); CAS-stale failures carry a server-chosen reason (upstream uses `stale info`-style text internally; ours is free to pick — clients display it verbatim). Human-readable diagnostics ride band-2. The substrate owns the framing (a report sent unwrapped when sideband was selected aborts real clients — observed `fatal: protocol error: bad line length character`), so handlers cannot get it wrong. `quiet` suppresses progress (which we never send), not errors — band-2 diagnostics are always permitted. 9. **Ref-name validation** (per-ref, after unpack): `gix_validate:: reference::name` plus alkgit's deny-list — the git-protocol.md security note, now pinned to the exact validation point and reason string (`funny refname`). The deny-list is a small constant in the transport layer (git-refname-invalid namespaces: names ending in `.lock`, `refs/` reserved prefixes used by our own metadata — the list lives with the validation code; exact contents are an implementation-time detail following `git check-ref-format` rules, not an architecture variable). 10. **Atomic**: when `atomic` was negotiated and any ref fails, every other ref reports `ng atomic push failure` (observed live). Implemented by preparing the whole `gix-ref` transaction and committing it once; per-ref results map from the transaction outcome. 11. **Push-options**: negotiated (`push-options` advertised only when the assembler enables it; default off in v1), request section parsed (bare pkt-lines between the command flush and the pack) and surfaced to the ingest/refs seam as per-push metadata: the prepared binding carries `push_options: Option<&PushOptions>` — the parsed `(key, value)` pair list, verbatim and un-interpreted — present only when `push-options` was negotiated (`None` until the config gate opens; review 001 N-4's additive-signature pin, so opening the gate later is a value change, not a trait redesign). Rejection of specific options is `ng `; the section's absence when not negotiated must not be parsed as commands (the flush boundary is authoritative). 12. **Http framing for push** (POC-3 facts, now extended): small pushes arrive Content-Length; large pushes arrive chunked, and the client may send a 4-byte `0000` probe POST first, answered 200-empty. The POST Content-Type is `application/x-git-receive-pack-request`; the response is `application/x-git-receive-pack-result` (sideband-wrapped report when negotiated), ending at a flush — the stateless substrate (ADR-005) owns these rules unchanged. ## Consequences - **Positive**: every wire shape in the push path is POC/capture-grounded (not grammar-inferred); the ingestion composition binds ADR-004's tools to concrete calls, with thin-pack handling first-class upstream (`.keep`-guarded pack landing, base lookup); `atomic` falls out of the single-transaction commit; the honest-advertisement invariant holds with a small served set; OQ-04's seven unknowns all have decisions. - **Negative**: shallow pushes are rejected (clients see a clear error, not a silent decline) — a real capability gap vs `git receive-pack`, accepted for v1 symmetry with fetch's shallow decline. `push-options` default-off adds a config surface (additive, two-way). - **Neutral**: the V0 framing of push coexists with the V2 fetch path in the same session layer; the state machines differ per command, which the substrate already models. CAS-before-unpack fail-fast remains an optimization backlog item (tracked: `tasks/architecture/oq-13-cas-failfast.md`). ## References - `docs/research/push-captures.md` (the captures this decision is built on) - `docs/research/poc3-findings.md` (request streaming, http framing facts) - ADR-003 (V2-first — fetch only; honest advertisement everywhere), ADR-004 (pack pipeline: ingestion tools), ADR-005 (substrate owns framing), ADR-007 (ACL before advertisement), ADR-009 (budgets) - gitoxide: `gix-pack` `Bundle::write_to_directory[_eagerly]` (`thin_pack_base_object_lookup`, `.keep` handling), `data::input` (`LookupRefDeltaObjectsIter`, `Mode::Verify`, `EntryDataMode`), `gix-ref` transactions (`PreviousValue::MustExistAndMatch`), `gix-fsck` (`Connectivity`), `gix-validate::reference::name` - transport.md §receive-pack, backend.md §"The trait family" (GitPackIngest, GitRefs), doors.md