Files
alkgit/docs/architecture/decisions/013-receive-pack-state-machine.md
T
glm-5.3-flash 11ceead6fd docs(architecture): N-4 + N-5 — push-options trait param, unborn-HEAD rider
- N-4: GitPackIngest's prepare binding carries push_options:
  Option<&PushOptions> (parsed (key, value) pairs, verbatim and
  un-interpreted; None until the config gate opens) — pinned in
  ADR-013 §11 and backend.md's trait description so opening the
  config gate later is value-additive, not a trait redesign
- N-5: ls-refs=unborn verification recorded as a rider in
  transport.md §ls-refs + tracker task tasks/architecture/
  n5-unborn-head-rider.md (unborn fixture, real client, both
  substrates; drop the token if it cannot be served — ADR-003)
- review 001: N-4, N-5 marked resolved

verification: cargo test, clippy -D warnings, fmt --check, doc — clean
2026-09-30 04:19:37 +00:00

238 lines
13 KiB
Markdown

# 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/<version>`
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 (<old> <new> <ref>, 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=<current> 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 <sha>`
lines before the commands. Real receive-pack accepts them when the
pack completes history and rejects with
`ng <ref> 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<Never>` — 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 <reason>`.
- 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 <ref> 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 <reason>`, per-ref `ok <ref>` / `ng <ref> <reason>`,
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 <reason>` + 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 <ref> 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 <ref> <reason>`; 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