- ADR-014: the multi-round ack loop, grounded in duplex git 2.43.0 captures cross-checked against fetch-pack.c: no-done rounds get acknowledgments (ACK <oid> per recognized have, NAK when none, flush; never ready so FLUSH is always the terminator), the done round generates closure(wants) - closure(haves) with no cross-round server state (clients re-send wants + commons every round), wait-for-done stays (no capability change), want-less rounds answered empty, the ack check is a new GitPackGen::common_haves seam (honest boundary at the trait), budgets unchanged kinds - docs/research/negotiation-captures.md: the normative negotiation record (grammar, client behavior, malformed-section failure modes) - transport.md: fetch section rewritten to the decided loop; references updated - OQ-02 resolved Verification: cargo test / clippy -D warnings / fmt --check / doc pass
5.9 KiB
ADR-014: V2 multi-round negotiation — the ack loop
Status
Accepted (resolves OQ-02)
Context
ADR-003 shipped v1's fetch as full-closure-on-done with
fetch=wait-for-done advertised, explicitly deferring the multi-round
ack/NAK design to OQ-02 ("lands after the done-path works end-to-end").
The gap: real clients with shared history send no-done rounds first
(want + have lines), and the server must answer them with a
grammatically valid acknowledgments section — even a done-only server
has to answer something. The open questions were the ack/NAK logic,
whether wait-for-done stays, and how the loop composes with the
GitPackGen seam and ADR-009's budgets.
Resolution method: duplex git fetch captures against a ground-truth
negotiation mock (git 2.43.0), cross-checked against fetch-pack.c
v2.43.0 (do_fetch_pack_v2 / process_ack / send_fetch_request).
Captures and the source-derived grammar are recorded in
docs/research/negotiation-captures.md.
Decision
v1 serves the full ack loop; no ready; wait-for-done stays.
-
Round grammar served: a no-
doneround (want + have lines + flush) is answered with:acknowledgments [ACK <oid>]* — one per have we recognize as common (rule below) [NAK] — only when no ACKs in the round 0000 FLUSH — section terminator (we never send `ready`, so FLUSH is always our terminator — the DELIM variant exists only for `ready` responses)ACK rule: a have is acked iff the object exists in the repo's odb AND is a commit. (Clients send commit haves; acking non-commits would feed the client's negotiator garbage. The existence check is also the honest-boundary rule: never ack what we cannot subtract.) NAK placement matches upstream: absent when ACKs were sent.
-
Done round: pack generation runs exactly as ADR-003/ADR-004 define, with the request's haves as the boundary set: closure(wants) − closure(haves). The client re-sends its acked commons and its current-round haves in the done request, so the boundary set is complete in that single request — no cross-round server state is required (client-side guarantee, observed and source-confirmed). An empty resulting pack (client already has everything) is a valid zero-object packfile response.
-
Want-less rounds are answered empty. A fetch round with no want lines (captured: an up-to-date client sends an empty round before exiting) gets an empty acknowledgments section —
acknowledgments+NAK+ flush, no pack. The client exits without sending a done round; nothing else is required. This is the degenerate no-work shape, not an error. -
wait-for-donestays;readyis never sent. The ack loop needs no capability-text change (the acknowledgments section is grammar, not a capability — clients send no-done rounds regardless of the advertised fetch features, captured). Keepingwait-for-donehonest means: pack is sent only ondone, never onready. The efficiencyreadywould buy is at most one round-trip per fetch (and the everything-already-common case is bounded by the want-less round rule above). Advertisement staysfetch=wait-for-doneexactly as ADR-003 pinned. -
The ack check is a backend seam:
GitPackGengains one method —common_haves(repo, haves) -> recognized subset(exists + is-commit per have). The wire layer acks exactly its output. This keeps the honest-boundary decision (never ack what we cannot subtract) at the trait boundary, where an embedder with a foreign object store can implement it, and keeps the wire layer odb-free (ADR-010). -
Budgets (ADR-009): the loop consumes max-negotiation-rounds (tens) and max-haves-per-round (the default sits at/above the client's legitimate stateless ceiling of 16384 — see the amended ADR-009 table); breach ends the session per ADR-009's fail-closed rule. A client that keeps sending rounds without progressing hits the rounds cap; no new budget kinds are introduced.
-
Statelessness preserved: each http POST is one round; the client re-sends wants + acked commons every round, so the stateless substrate (ADR-005) needs no negotiation state, and the duplex substrate needs none either. This is what makes the loop cheap on both substrates.
Consequences
- Positive: incremental fetches get real negotiation — the done
round's haves subtract shared history from the pack (the efficiency
the OQ existed for), and the client's negotiator stops wandering our
unknown history early (ACKs bound
in_vainat 256 instead of letting it run to exhaustion). The done-only fallback is the loop's degenerate case (NAK-only responses), so the POC-proven path remains the correctness floor. No capability change; ADR-003's advertisement text stands. - Negative: the ack loop adds one backend-trait method (freeze
inventory, OQ-03) and one wire-shape surface that must match
fetch-pack.c's parser exactly (the capture log records the failure modes of getting the section wrong). Theready-based early pack is declined — a possible future optimization, additive (capability-text change), two-way until published. - Neutral:
no-done(the client capability that would let the server's ready replace done) is not applicable while we never sendready.
References
docs/research/negotiation-captures.md(the grammar + client-behavior record this decision is built on; includesfetch-pack.cv2.43.0 cross-checks)- ADR-003 (V2-first, advertisement values — unchanged by this ADR), ADR-004 (pack generation, haves boundary), ADR-005 (substrates), ADR-009 (rounds/haves budgets)
- gitoxide:
gix-packgeneration pipeline (closure subtraction),gix_odbexistence checks (the gix impl ofcommon_haves) - transport.md §fetch, backend.md §"The trait family" (GitPackGen)