docs(architecture): resolve OQ-02 — V2 negotiation ack loop (ADR-014)
- 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
This commit is contained in:
1 parent
e76f91f6d7
commit
d6d013e522
2 files changed
+232
No files matched your search
@@ -0,0 +1,123 @@
|
|||||||
|
# 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.**
|
||||||
|
|
||||||
|
1. **Round grammar served**: a no-`done` round (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.
|
||||||
|
|
||||||
|
2. **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.
|
||||||
|
|
||||||
|
3. **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.
|
||||||
|
|
||||||
|
4. **`wait-for-done` stays; `ready` is 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). Keeping `wait-for-done` honest
|
||||||
|
means: pack is sent only on `done`, never on `ready`. The efficiency
|
||||||
|
`ready` would 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 stays `fetch=wait-for-done` exactly as
|
||||||
|
ADR-003 pinned.
|
||||||
|
|
||||||
|
5. **The ack check is a backend seam**: `GitPackGen` gains 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).
|
||||||
|
|
||||||
|
6. **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.
|
||||||
|
|
||||||
|
7. **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_vain` at 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). The `ready`-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 send
|
||||||
|
`ready`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `docs/research/negotiation-captures.md` (the grammar + client-behavior
|
||||||
|
record this decision is built on; includes `fetch-pack.c` v2.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-pack` generation pipeline (closure subtraction),
|
||||||
|
`gix_odb` existence checks (the gix impl of `common_haves`)
|
||||||
|
- transport.md §fetch, backend.md §"The trait family" (GitPackGen)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Research: V2 multi-round fetch negotiation captures
|
||||||
|
|
||||||
|
**Status**: complete
|
||||||
|
**Date**: 2026-09-25
|
||||||
|
**Client**: git 2.43.0 (duplex `git fetch` over git:// against a
|
||||||
|
ground-truth mock upload-pack that served the negotiation grammar; client
|
||||||
|
parsing rules confirmed against `fetch-pack.c` v2.43.0 `do_fetch_pack_v2`
|
||||||
|
/ `process_ack` / `send_fetch_request`). This is the normative
|
||||||
|
negotiation record ADR-014 is built on.
|
||||||
|
|
||||||
|
## Round grammar (definitive, source + captures)
|
||||||
|
|
||||||
|
A fetch round request is: `command=fetch`, caps (agent, object-format),
|
||||||
|
`0001` delim, then args (thin-pack, no-progress, ofs-delta, want lines,
|
||||||
|
have lines, done), then `0000` flush. **Wants are re-sent every round**
|
||||||
|
(`add_wants` runs unconditionally in `send_fetch_request`); common haves
|
||||||
|
are re-sent every round too (`add_common` — the accumulated common set),
|
||||||
|
plus new haves from the negotiator (`add_haves`).
|
||||||
|
|
||||||
|
Server response to a round **without** `done`:
|
||||||
|
|
||||||
|
```
|
||||||
|
acknowledgments
|
||||||
|
[ACK <oid>]* — each have the server recognizes (common)
|
||||||
|
[NAK] — when no acks in the round (observed: NAK alone)
|
||||||
|
0000 FLUSH — always, when ready was NOT sent
|
||||||
|
```
|
||||||
|
|
||||||
|
- The acknowledgments section terminates with **FLUSH when no `ready`**
|
||||||
|
and **DELIM (0001) when `ready` was sent** (source: `process_ack` —
|
||||||
|
"a DELIM is expected if ready is sent, and a FLUSH otherwise").
|
||||||
|
- If `ready` is sent, the packfile section follows in the same response
|
||||||
|
(optional shallow-info / wanted-refs / packfile-uris sections first,
|
||||||
|
each DELIM-terminated). We never send `ready` (see ADR-014), so our
|
||||||
|
section always ends at FLUSH.
|
||||||
|
- Client-side validation (observed by feeding it wrong shapes): acks
|
||||||
|
section containing `packfile` → `fatal: unexpected acknowledgment
|
||||||
|
line: 'packfile'`; acks + FLUSH after `ready` → `fatal: expected
|
||||||
|
packfile to be sent after 'ready'`; acks header missing → `expected
|
||||||
|
'acknowledgments'`.
|
||||||
|
|
||||||
|
## Client round behavior (captures + source)
|
||||||
|
|
||||||
|
- Have batching: first round sends up to 16 haves (`INITIAL_FLUSH`),
|
||||||
|
doubling per round (32, 64, … 16384 stateless / +32 duplex) —
|
||||||
|
client-side only; it is our budget's ceiling, not our obligation (the
|
||||||
|
ADR-009 haves default should sit at or above 16384 so legitimate
|
||||||
|
negotiations are not clipped).
|
||||||
|
- `done` is sent when: the negotiator has no more haves to offer
|
||||||
|
(`!haves_added`), or `seen_ack && in_vain >= 256` (`MAX_IN_VAIN`).
|
||||||
|
- Want-less round (captured): a client with everything up-to-date sends
|
||||||
|
a fetch round with no want lines and exits; the correct server answer
|
||||||
|
is an empty acknowledgments section (`acknowledgments` + `NAK` +
|
||||||
|
flush, no pack). The client then exits without a done round.
|
||||||
|
- On receiving ACKs (no ready): client marks commons, sends another
|
||||||
|
round (wants + common haves + new haves). Observed: after 5 ACKs the
|
||||||
|
client re-sent the same 5 as common haves plus continued negotiation.
|
||||||
|
- On NAK-only: client proceeds to `done` quickly when out of new haves
|
||||||
|
(observed: 1 have → NAK → next round want + done).
|
||||||
|
- The `done` round carries wants + common haves + (maybe no new haves) +
|
||||||
|
`done`. **The server must therefore honor haves present in the done
|
||||||
|
request when generating the pack** — that is where negotiation pays
|
||||||
|
off.
|
||||||
|
- On the `done` round the response has **no acknowledgments section**
|
||||||
|
(client goes straight to FETCH_GET_PACK): optional sections then
|
||||||
|
`packfile` + sideband + flush (POC-1's observed shape, re-confirmed).
|
||||||
|
|
||||||
|
## ACK line format
|
||||||
|
|
||||||
|
`ACK <oid>` — bare 40-hex oid accepted (`process_ack` parses `ACK ` +
|
||||||
|
exactly hexsz chars; suffixes like `continue`/`common`/`ready` after the
|
||||||
|
oid are the V0 grammar, not required in V2). `NAK` lines are tolerated
|
||||||
|
anywhere in the section (`continue` in the ack loop).
|
||||||
|
|
||||||
|
## Statelessness
|
||||||
|
|
||||||
|
Each POST (stateless http) re-derives cleanly: the client re-sends wants
|
||||||
|
and all common haves every round, so the server needs **no cross-request
|
||||||
|
state** — each round's haves are the complete common view. Duplex needs
|
||||||
|
no session state either (client re-sends). POC-1/POC-3 substrate facts
|
||||||
|
apply unchanged.
|
||||||
|
|
||||||
|
## Captures of interest (from mock negotiation runs)
|
||||||
|
|
||||||
|
- Round 1 `want + 5×have`, no done → `acknowledgments` + 5×`ACK <oid>`
|
||||||
|
+ `ready` + DELIM + `packfile` + band: client accepted framing and
|
||||||
|
consumed the pack section (failed later only because the mock's pack
|
||||||
|
was empty — `did not send all necessary objects`, which is the correct
|
||||||
|
client response to a pack missing wanted objects).
|
||||||
|
- Round 1 `want + 1×have`, no done → `acknowledgments` + `NAK` + FLUSH →
|
||||||
|
client round 2: `want + done` (haves remembered server-side are NOT
|
||||||
|
needed; the client trusts the server saw round 1's haves — but the
|
||||||
|
done round's pack correctness does not depend on it when the server
|
||||||
|
generated no pack in round 1: nothing was subtracted yet).
|
||||||
|
- After ACKs without ready (mock bug shapes): client re-negotiates,
|
||||||
|
re-sending wants + common haves. Errors observed for malformed
|
||||||
|
sections confirm the client's section parser is strict.
|
||||||
|
|
||||||
|
## Implications for alkgit (design, not capture)
|
||||||
|
|
||||||
|
1. Serve the no-done round with `acknowledgments` + `ACK <oid>` per
|
||||||
|
recognized have + `NAK` when none + FLUSH; never `ready` (honest
|
||||||
|
under `fetch=wait-for-done`).
|
||||||
|
2. On the done round, generate the pack as closure(wants) minus
|
||||||
|
closure(haves) — the GitPackGen seam already takes (wants, haves,
|
||||||
|
limits), so have-honoring is the negotiation payoff and free at the
|
||||||
|
trait boundary.
|
||||||
|
3. No cross-round server state required (client re-sends wants + common
|
||||||
|
haves every round). Budgets per ADR-009 (rounds cap, haves cap).
|
||||||
Reference in new issue
Block a user