S31 Step 15: packument handler
Part of #290, **S31 npm virtual**. This slice composes code that is already merged: S11's hosted npm reads, S15's remote legs, and S13's resolution engine in `internal/virtual`, which Step 12 gave its first caller. The three `npm_virtual_*` tables land in `internal/datastore/migrations/`, the resolution reads in `internal/datastore/`, and the loading slice, the merge, the collection, and the serve surface in `internal/format/npm/`.
**References:** [S31 spec](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S31-npm-virtual.md) · [S31 plan](https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s31-npm-virtual.md) (see "Step 15").
**Type:** `feat` | **Depends on:** Step 14 (composition root) (tracked as blocked-by links below).
## The cross-upstream merge kept this step's scope and re-pointed its criteria
!2018's decision, at [note 3774142678](https://gitlab.com/gitlab-org/ops/artifact-registry/-/merge_requests/2018#note_3774142678), replaced the winner-only packument with a merge across every eligible upstream. The spec side landed with !2338, merged as `c5ed5ef1d`. #266 tracks the rest.
This route still mounts one handler on one slot. What changed is what it is asserting: it serves a **merged** document rather than a winner's, so it elects nobody. The plan-side amendment travels as !2352, and **no MR for this step may open before it merges**, under the plan-before-code guardrail.
**One acceptance criterion changes character rather than number, and it is the one worth reading twice.** "A definite failure never reaches the network" used to be a short-circuit assertion: a warm read at position N with definite failures above it was served with no upstream HTTP call, and the fresh position stopped the read. Under the merge the fresh position is **collected rather than elected**, so the read does not stop there. What the criterion now pins is narrower and still real: a definite failure issues no request. A suite that keeps the old assertion passes against a collection that never collects.
## Scope
`GET` and `HEAD` on the packument route for a `kind=1` repository, and swapping its slot. Sibling of Steps 16 and 17.
## Files
- `internal/format/npm/virtual_packument.go` (Create): variant selection from `Accept` chosen once and carried through to **every position's input**, with a remote position's cold Fetch forwarding the same `Accept` upstream. The mechanism is the kind-encoded `npmremote.Target` Step 12's facade builds. Under the merge the variant is chosen **once per read and not once per position**, which is what Spec AC, Packument merge #21 pins: a per-position renegotiation would serve a document mixing both variants' entries.
- `cmd/artifact-registry/wire_npm_virtual.go` (Modify): `npmVirtualPackumentSlot` only.
- `.claude/skills/run-artifact-registry/SKILL.md`, `driver.sh` (Modify): this route's section and this route's smoke check only. The check already exists from Step 14 and this step flips its expected status from `501`; the hard-coded smoke-check count is **not** touched here, because it is a single-writer surface and three siblings each bumping it would conflict.
- the paired test files (Create).
## Acceptance
Spec AC, Resolution, cited by name because the amendment renumbered the list: **"The metadata routes merge and the tarball route does not"**, the criterion the merge added and this route's headline; "Hosted-and-remote mix"; and "A definite failure never reaches the network", asserted for a denied position and for a health-marked one, because the two reach the no-request state down different arms. Plus the packument slice of "A just-published package is found on its hosted upstream", where the hosted position now **contributes to** the merge rather than winning it.
Spec AC, Packument re-point #1 (a remote contributor's entries re-point, and no `dist.tarball` names the upstream host or the remote repository), #4 (a hosted contributor's entries re-point), and #8 (the abbreviated variant and its `Content-Type`, with the `Accept` forwarded upstream on a cold fetch).
Spec AC, Concurrency #1 and #2 in full, both driven through this route, since this is the first step where a virtual read reaches an upstream.
Plus Spec AC, Validation and reads #1's packument third (`HEAD` matches `GET`) and #4 (an invalid package name returns `422` before any resolution).
## Tests
Handler tables across **contributor mixes** rather than across winner kinds, since this route elects nobody: one remote alone, one hosted alone, and both together with a version each side holds and one each holds alone.
The just-published hosted window end to end. The definite-failure case with a counting `httptest` upstream asserting zero calls to the denied and the unhealthy position, and **one call** to the fresh-row position's peer, which is what distinguishes a collection from the short-circuit this test used to assert. The two concurrency criteria, including the cross-repository one that also issues a direct read of the shared remote. `HEAD` parity. Validation rejections proving no resolution ran.
Plus the **cache-parity** composition test: a virtual cold fill and a standalone remote cold fill of the same package against the same remote repository write byte-identical `npm_remote_metadata_files` rows, and the document each route then serves carries its own base. This is the assertion that catches a fetch-options mismatch, which no test on either route alone can see. It runs on a **multi-remote** fixture, at least two remote positions with different bases, because a single-remote list cannot distinguish a per-position rewriter from a request-global one: with one base to build on, both write the same row. The parity test runs once per packument variant, and a **cross-variant** case joins it: an abbreviated fill followed by a full-variant read must not short-circuit on the abbreviated row, and the reverse, with the served `Content-Type` asserted each way.
## Size
Source ~400 · Test ~1,230 · Total ~1,630
task
GitLab AI Context
Project: gitlab-org/ops/artifact-registry
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/README.md — project overview and setup
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/AGENTS.md — AI agent instructions
- https://gitlab.com/gitlab-org/ops/artifact-registry/-/raw/main/CLAUDE.md — Claude Code instructions
Repository: https://gitlab.com/gitlab-org/ops/artifact-registry
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD