Compose a per-repository remote.SingleFlight on the container fill path

Problem

resolution.max_concurrent_fills_per_repo is enforced by a counting semaphore inside remote.SingleFlight (internal/remote/singleflight.go, the sem field) and nowhere else. remote.Fetcher carries no concurrency bound of any kind.

internal/format/oci/remote_operations.go composes remote.Fetcher directly. That is a deliberate deferral of upstream-fetch coalescing, recorded in docs/plans/2026-07-30-container-remote.md under Step 13, which ships three S16 coalescing criteria skipped. What the plan did not record until now is the second thing the layer carries: with SingleFlight absent, the container fill path has no per-repository in-flight limit at all.

While no arm fetches, this is unreachable — the read arms on main serve cached content and answer a 501 on a miss. Once the miss-fill arms land (plan Steps 14 and 15), N concurrent cold pulls of one repository become N upstream GETs, N staging blob-upload sessions, and N object-store writes, bounded only by the HTTP server's own concurrency. Container layers are the largest transfers this service makes, so this is the format where it costs most: duplicate upstream egress, duplicate object-store writes, and a plausible upstream rate-limit ban on a CI fleet cold-starting one base image.

A real pull is not the cheapest way to spend one of those fetches. Step 15's blob arm (!1894 (merged)) answers a cold HEAD by draining the whole layer to io.Discard to commit the cache row, and answers a ranged miss, the unsatisfiable range included, by fetching the whole blob before the 206 or 416. A request that costs its sender almost nothing then costs this service one full upstream fetch, one staging session, and one object-store write, and N concurrent ones for one path stay N of each until the first commit gives later reads a row to hit. The coalescing and the per-repository cap this issue composes bound both shapes. The single-request full fetch stays: fill-before-answer is the contract docs/specs/S16-container-remote.md fixes for the deferred paths, and each fetch is bounded by the blob size-cap class.

Scope

Compose one long-lived remote.SingleFlight per remote container repository and route the Fetch helpers through it.

internal/format/maven's mavenRemoteFlights is the shape: one instance per repository, rebuilt when the row's URL changes, reclaimed by a bounded sweep. Maven does hold a long-lived flight — remote_artifact.go's per-repository flights comment says the Handler holds one *remote.SingleFlight per remote repository, shared by every request it serves. What Maven does not hold long-lived is the cache store: remote_store.go says a RemoteCacheStore is per-request because it captures now at construction, so flightFor wraps it in a per-call flightCacheStore.

oci.RemoteCacheStore captures no reference time, so it needs no equivalent adapter. Its doc comment does carry a rebuild trigger of its own, and states it as an obligation rather than a property: an instance shared across requests has to be rebuilt when the row changes. Nothing does that today, because the store is built per fetch.

The container request builder is the seam that needs work first. NewSingleFlight captures the RequestBuilder and holds it for the repository's lifetime. The container builder resolves its Authorization header inside BuildRequest from the token cache, which settles credential freshness and nothing else: it still holds the client's Accept values and a snapshot of the row's url, auth_status, and auth_url, and BuildRequest takes neither per call. Composed as-is behind a long-lived flight, it would serve one request's Accept values and a stale url/auth_url snapshot to every later request for that repository. Closing that gap is part of this issue.

Per CLAUDE.md (Type and Seam Conventions), a shared-instance seam needs a composition test: no constructor signature can detect a caller passing a per-request instance, and the symptom (halved coalescing, doubled fill cap) fails no unit test. Prove it end to end — concurrent requests for the same path must produce exactly one upstream request.

Also closes

The three S16 coalescing acceptance criteria the Step 13 plan entry ships skipped. The coalesced-follower one additionally needs the follower-model change in #320.

Related to #288

Edited by Sylvia Shen