feat(npm): serve dist-tags from a remote repository (S15 plan: 13/16)
🎯 Summary
Adds npm.RemoteDistTagsHandler, the GET/HEAD dist-tags read for a kind=remote repository, and its suite. The document is cached as that package's dist-tags metadata row in its own right, never derived from a cached packument.
Five commits: the route and its suite, then fix(npm): set nosniff at the handler and map a lost leader to 503, then fix(npm): send Retry-After 1 on a coalescing 503 and mount dist-tags, then test(npm): pin the shared remote flight registry at the composition root, then test(npm): scope the shared-registry mutation claim to what was run. Each later one is kept separate so a reviewer who saw the previous can read the delta; squash on merge is fine. The last two change no production behavior: they are the second maintainer pass's composition test, one added assertion, and comment corrections.
Merge order. !1674 (merged) lands first, then !1686 (merged) (Step 14, tarball) rebases onto it. Both add a slot to buildNpmRemoteSlots and both touch wire_npm_remote_boot_integration_test.go, the run-recipe pair and the e2e catalog, which is the four-file overlap Step 13's Depends on already names. !1686 (merged)'s buildNpmRemoteSlots comment saying "The dist-tags slot will append its option here" becomes false on that rebase and is updated there.
🧩 The pieces
1️⃣ It is the second route over the shared remote-read surface
The handler owns only the dist-tags-facing half: the package name read off the path, the target kind, the media type, and the not-found vocabulary its remoteReadPolicy carries. Everything else is Step 11's, taken whole rather than copied.
- The per-repository seams arrive through
RemoteOperationsFinder, so one repository holds one coalescing flight however many routes reach it.TestRemoteDistTags_NotDerivedFromTheCachedPackumentdrives both routes over the one provider. - The response half is
remote_document_serve.go: the serve dispatch, the document stream, the header build, the conditional304, and the307to storage. - The failure mapping is
remote_read_errors.go, which takes the not-found pair as a parameter, so this route answerspackage_not_foundwhile sharing every other arm.
That is what keeps the served headers, the relay rule, the Retry-After scope, and the no-store on every envelope identical to the packument route's rather than a second copy that can drift.
2️⃣ Serve
A fresh row serves with no upstream call, a stale one revalidates with If-None-Match, and a miss, or a stale row with no stored validator, is fetched in full with every dist.tarball rewritten in-stream into both the client's response and the cached blob. Freshness follows the remote's metadata_cache_validity_hours.
ServeFromCache and ServeFromCacheDegraded are served identically, deliberately. Every dist-tags document this route serves is already a snapshot bounded by that window rather than a live read, and a client has no action to take on the difference. Distinguishing it on the wire would mean either a shorter freshness window, multiplying load onto the upstream that is already failing, or a staleness header no npm client reads. remote.Standalone logs the fallback with the path and the upstream error, which is where an operator reads it.
3️⃣ Response shape
A strong ETag over the served bytes rather than the upstream's own validator, Cache-Control: private, max-age=0, Vary: Authorization, a client If-None-Match answered 304, and the 307 to storage under the instance-wide delivery mode. No upstream header is forwarded on any path, so an upstream Set-Cookie or WWW-Authenticate cannot reach a client.
Vary is the one header the two routes deliberately differ on. The packument URL serves two representations and varies on Accept as well; this URL serves one and negotiates nothing, so naming Accept would split a shared cache's entries by a header that cannot change the answer. TestRemoteDistTagsHandler_VaryOmitsAccept pins it.
A cache-fill 200 is the one served response with no validator. The ETag is computed over the transformed bytes and those exist only once the fill commits. The next read of the path is a cache serve and carries it, so the absence is confined to the first read of a document. This is what the spec asks for on this route rather than a divergence from it: docs/specs/S15-npm-remote.md's ### Validators on a proxied metadata read says a serve streamed live from the upstream carries no ETag, for exactly this reason. (An earlier revision of this description claimed a divergence tracked in #749. That claim came from the packument route, whose own Serve paragraph does ask both variants to carry one; #749 remains open against that route, and its pointer now lives on RemotePackumentHandler.ServeHTTP alone rather than being copied here.)
4️⃣ Error mapping
An upstream 404 answers package_not_found; a 500 answers internal_server_error and any other 5xx propagates as upstream_unavailable; every other 4xx relays the upstream status under upstream_rejected, with no upstream header travelling.
Retry-After rides a 503 and nothing else, including a relayed upstream 503. Its value is not one number. An arm answering a health verdict, and a relayed 503, carry health_check.scheduled_interval derived at construction. The two coalescing arms carry 1, which is what docs/specs/S13-virtual-remote-foundation.md's #### Retry-After on a coalescing failure fixes across Maven, npm and Container, forbidding the health interval by name; its sibling section, the health-interval rule, excludes ErrLeaderCanceled and ErrSingleFlightTimeout explicitly. writeRemoteProxyEnvelope takes the value per call rather than off the policy, the shape maven.RemoteFetchOutcome.UpstreamRetryAfter already has.
Every envelope carries Cache-Control: no-store. The relayed 404 is the case that needs it most: RFC 9111 makes 404 heuristically cacheable, unlike 500 and 503, so without the directive a shared cache could retain a proxied absence that publishing the package resolves. The hosted reads never face this, because a hosted 404 is authoritative.
Note for anyone re-reading this MR. An earlier revision of this branch argued that every non-
4044xxshould answer a generated503, and amended the S15 Error Cases table to say so. That position did not survive: !1688 (closed) took the opposite view on the same row, merged api-style.md settles it against the old one, and Step 11 landed the amendment. The concern behind the old position is answered rather than dismissed: what made a relayed401harmful was the upstream'sWWW-Authenticatereaching an npm client and reading as a challenge from this registry, and the relay rule forbids forwarding any upstream header, so the challenge never travels. The distinctupstream_rejectedcode is what tells a client the refusal is the registry-to-upstream relationship rather than a grant this service withheld. The429case improves too: the status now reaches a client that can act on it. This branch carries no spec hunk.
5️⃣ Write deadline
Go arms the connection write deadline once, from server.timeouts.write (10s in config.example.yaml), when the request headers are read. Every slow outcome here outlives it: the upstream fetch runs to request_total_timeout (30s default), a coalesced follower waits up to single_flight_wait_timeout (5m default), and the cache-fallback serve opens a blob after a fetch has already failed. Past the deadline net/http buffers the response, the flush fails, and the connection closes with the client having seen no status line, so the 503, the Retry-After every such response is required to carry, and a slow 200 alike would all be lost.
armRemoteReadResponseDeadline re-arms before anything that can block. It is the packument route's, shared rather than reimplemented, and the budget is summed at construction from the two knobs plus a grace, so raising either cannot silently reintroduce the gap.
🔗 Follow-ups this MR opened
A degraded serve is invisible in the metric surface. When remote.Standalone falls back to a stale cached document because the upstream is failing, remote.ServeFromCacheDegraded is served identically to a fresh hit (deliberately, see Serve above) and reported code=success, so the condition is neither rateable nor alertable. The counter that closes it, ..._npm_remote_cache_events_total, is already declared in docs/specs/S15-npm-remote.md and scoped to Step 15, which also owns the internal/metrics/cardinality.go widening its first hit_stale emission needs. Recorded on #560, the existing Step 15 observability issue, rather than filed separately.
#778: ErrFillAbandoned answers 503 here and 500 on Maven, and S13 names neither. Raised in review. Carries the S13 wording, the Maven arm, and the removal of the explanatory paragraph on the npm arm once the spec carries the rule.
#780: proxied npm metadata carries a fixed Cache-Control against S11's visibility-derived directive. Raised in review. The constant predates this branch; this MR extends it to a second route, which is why it was raised here. Nothing diverges while closed beta is private-only. Cross-linked with #749, which covers the missing validator on the same responses.
#785 (closed): S15's Error Cases paragraph states the Retry-After rule unqualified, and so covers the coalescing 503s S13 excludes. Raised in review. S15 governs this route and says every 503 upstream_unavailable carries health_check.scheduled_interval; that is false for the two coalescing arms, which carry 1. It is recoverable rather than contradictory, because the link inside S15's sentence resolves to the S13 section that excludes both sentinels by name, but a reader who stops at S15 does not get there. The issue carries the spec edit; the code needs none, and remoteCoalescingRetryAfterSeconds's doc comment names the paragraph and points at it.
All three were searched for before filing, per the duplicate-search guardrail: ErrFillAbandoned, the visibility-derived Cache-Control, and Retry-After each return no existing issue covering them. The Retry-After search returns #687 (relayed upstream status across four specs) and #688 (wiring the derived value into each path), and neither reaches the coalescing carve-out.
🔌 The mount
buildNpmRemoteSlots returns npm.WithRemoteDistTagsHandler(distTags) alongside the packument option, so the route is reachable in every wired deployment. Both handlers are built from the same provider value: npmremote.FlightRegistry holds one remote.SingleFlight per repository, and a second registry would halve the coalescing and double the effective fill cap with nothing in any signature able to detect it.
An earlier revision of this description deferred the mount to a later MR, on the grounds that it owed the run-recipe update and the e2e scenarios. That was wrong twice over. Step 13 of the plan assigns both of those to this step, and no later step takes the wiring line: Step 14 scopes its slot to the tarball, and Steps 15 and 16 name no wiring file. Deferring would have shipped the route unreachable with the plan's row 13 reading as complete.
TestMountSlugAnchoredFormats_NpmRemoteDistTagsIsMounted in wire_npm_remote_boot_integration_test.go pins it through the production mount, with the same four arms as its packument sibling. It asserts the upstream path on every subtest that reaches the upstream, which is the route-specific half: a slot answering out of the packument endpoint would give the same 404 and the same 200 shape while caching under the wrong row, so the status alone cannot separate them. That includes the transport-failure subtest, which reaches the upstream too: remotetest.FakeUpstreamDoer.Do records the call before it returns the scripted error.
The sharing itself is now pinned, and it is the one property nothing else could see. buildNpmRemoteSlots's doc comment says a second provider is undetectable by any signature. It was undetectable by any test as well: splitting it into two buildNpmRemoteOperations calls left the whole tree green, because every other test drives one route at a time. TestMountSlugAnchoredFormats_NpmRemoteRoutesShareOneFlightRegistry mounts the builder with max_concurrent_fills_per_repo at 1 and drives both routes at one repository. remote.SingleFlight takes a fill slot before dispatching any path's fetch, so while the packument leader holds the only slot, a dist-tags read cannot start one: it waits out single_flight_wait_timeout and is answered 503 with the coalescing Retry-After. Two registries would hand it a slot of its own, and the fake upstream answers that fetch rather than blocking, so the split surfaces as a 200 instead of a hang. Verified by mutation: with the provider split in two, that test fails and no other does. This is what CLAUDE.md's A shared-instance seam needs a composition test asks for, and this MR is where the seam gains its second consumer.
The assertion lives in the boot integration file rather than in wire_npm_remote_test.go, which the plan names. That file does not exist at head and !1686 (merged) creates it, so writing it here too would be an add/add conflict on top of the buildNpmRemoteSlots one.
🛡️ Two changes to the shared surface, which the packument route also runs
Both came out of review of this MR, and both live in the code Step 11 shares between the remote metadata routes, so the mounted packument read gets them too. They are called out here rather than buried because they change live behavior that this MR's own route cannot yet exercise.
Every proxied 200 and 304 now sets X-Content-Type-Options at the handler. Before this, only the 307 to storage did. No client sees a new header: internal/server's HeaderHardeningMiddleware is mounted outermost and already puts nosniff on every response the service sends, so this is defense in depth, not a gap being closed. An earlier revision of this description claimed a remote repository was "giving the weaker of the two answers"; that was wrong on two counts, since the middleware covers it and the hosted packument read sets the header on no served body of its own either. What the change buys is what oci.writeRemoteReadError records for the same header: a test on this package can assert it without standing up the server chain, and a served document's headers stop depending on a middleware several layers out staying in that chain. The error envelopes this path writes still leave it to the middleware, unlike the OCI envelope path; bringing those in line is a separate change.
A follower whose coalescing leader went away is answered 503 upstream_unavailable with Retry-After, not 500. This is the one change here a client can observe. remote.ErrLeaderCanceled and remote.ErrFillAbandoned matched no arm of writeRemoteProxyError and fell to the default, which logs at ERROR and answers internal_server_error. Neither carries the upstream-transport marker remote.Standalone's fallback keys on, so no cached copy stands in and both genuinely reach the mapper. Charging one client's abandonment to this service's 5xx rate is the wrong reading, and the follower is still connected with nothing to back off by. The 503 is the one remote.ErrSingleFlightTimeout already takes: the sibling condition in the same non-eligible list, which also clears the moment a fresh leader is elected.
Both routes now also describe the cache-fill ETag the same way. docs/specs/S15-npm-remote.md says two things about it: its packument Serve paragraph asks both variants to carry one, and its later ### Validators on a proxied metadata read section says a live upstream serve carries none. The second is the specific statement and is what both routes implement, so the packument route's comment no longer calls it a divergence. Which statement the spec keeps is what #749 tracks.
No spec mandated the nosniff change: nothing in S13, S15, or api-style.md names the header, so no spec text moves.
The leader-gone 503 is a different matter, and an earlier revision of this description got it wrong. It claimed docs/specs/S13-virtual-remote-foundation.md's Retry-After rule "says nothing about a lost leader". S13's #### Retry-After on a coalescing failure names ErrLeaderCanceled directly, and the Maven commit that added that section, 1ff6e0ca3, is an ancestor of this branch. So the 503 is what the spec asks for rather than an argument from parity, and the value it carries is 1, which the third commit fixes.
remote.ErrFillAbandoned is the one sentinel S13 does not name. Grouping it with ErrLeaderCanceled is this mapping's own extension, because remote.Standalone.coalescedFetch holds the two to name one disconnect split only by which side of the tee race saw it first, and splitting the answer would make a client's status depend on race ordering. Maven still answers 500 for it. #778 carries the S13 wording and the Maven arm.
🔎 Review feedback
| Finding | Outcome |
|---|---|
| AppSec | No vulnerabilities identified. The review confirmed validation-before-use, header hygiene, SafeMessage envelopes, ETag-over-served-bytes, and no-store on errors. |
| The AppSec and Duo notes predate the current head. | Both are dated 2026-08-18; the rebuild that replaced this branch's contents landed 2026-08-20, and the review fixes after it. Two properties AppSec's report credits are no longer how the code works: it describes non-404 4xx as "mapped to a generated 503", which the relay rule reversed, and Cache-Control as "derived from the repository's visibility field ... with an explicit nil-row guard", which the fixed private, max-age=0 replaced. Both replacements are argued above, and the relay rule's no-upstream-header clause is what preserves the property behind the first. Flagging the staleness rather than leaving the label to imply the current diff was the reviewed one. |
Duo / AppSec: errNilRepositoryRow's message contains the word "error" |
No longer applicable. The rebuild drops the visibility read that sentinel guarded: remote_document_serve.go serves every proxied document under the fixed private, max-age=0, so the handler never loads the repositories row. |
| Branch review: the handler's doc comment claimed the composition root mounts it | Superseded. The comment was moved to future tense when the mount was still deferred; now that buildNpmRemoteSlots appends the slot, it is present tense again and says which composition still leaves the route on 501, namely one with no upstream client. |
| Branch review: the cache-fill arm claimed a spec divergence the spec does not have | Fixed, and explained under Response shape above. |
Branch review: #749's pointer paragraph was copied to a second site |
Fixed. It appears once, on RemotePackumentHandler.ServeHTTP. |
| Branch review: three comments still described dist-tags as a route that had not landed | Fixed in remote_packument.go, remote_read_errors.go, and internal/remote/standalone.go. The last one also claimed the packument route was the only builder of a remote.Standalone, which this MR makes false. |
Branch review: the mirrored suite dropped the 499 client-closed test |
Fixed. writeIfClientClosed is route-owned rather than shared, so it was deletable with all 33 tests still green; TestRemoteDistTags_ClientThatLeftIsStamped499 now pins it. Also restored: the cold-fill row counts, the superseded-attachment release, and an assertion that both routes share the repository's one flight entry. |
| Branch review: two comments justified this route with the packument's reasons | Fixed. The path scan is read off the decoded path for test-drivability and scoped names, not because of the matched pattern; the public_registry_url refusal is one rule across the remote metadata routes, not a dist.tarball this document does not carry. |
Maintainer review, blocking: the coalescing 503 carried Retry-After: 300 |
Fixed, and the finding was correct at every step. Per-call value; the two coalescing arms send 1. The inherited ErrSingleFlightTimeout arm moved too, which the reviewer left to my call: leaving it would put 1 and 300 on sibling sentinels inside one switch, under a spec sentence that names both. |
| Maintainer review, blocking: Step 13's mount, wiring assertion and run-recipe update were absent | Fixed. See The mount above. driver.sh smoke re-run at 41 passed, 0 failed. |
Maintainer review: remoteReadPolicy.notFoundCode's doc named two routes and this makes three |
Fixed. It now names the packument and dist-tags routes together on package_not_found, with the tarball's file_not_found distinguished by the version already being resolved. |
Maintainer review: the ErrFillAbandoned arm extends an S13 rule silently |
Fixed in the comment, and #778 carries the S13 wording and the Maven arm. |
Maintainer review: remoteDocumentCacheControl should name the S15 statement authorizing it |
Adjusted rather than taken literally: there is no such statement, which is the finding. The comment now says so, names S11's visibility-derived directive that S15 inherits without carving out, and points at #780. |
Maintainer review: two rdtNewFixture override arms were unreachable |
Fixed by carrying the tests over rather than dropping the arms: the coalescing-wait-timeout and cap-breach subtests, plus the reserved name row. |
| Maintainer review: two description claims did not match the branch | Fixed. The commit title is quoted correctly, and the test count was 34 rather than 24 when raised; it is 36 now. |
Second maintainer pass: the shared provider had no test, and none could be written from the existing suites |
Taken. TestMountSlugAnchoredFormats_NpmRemoteRoutesShareOneFlightRegistry, described under The mount above, using the fill semaphore the reviewer identified as the observable. Landed here rather than with the tarball slot, because this MR is where the seam gains its second consumer. |
Second maintainer pass: remoteReadPolicy.retryAfterSeconds's doc accounted for two of the three arms that pass it |
Fixed. Three arms pass it, and the reason differs across them: the unavailable-or-unhealthy verdict answers the health rule directly, the cap breach is a transport-level failure that rule already covers, and the upstream-refusal relay answers no verdict at all. Its two 503s take the value because remote.ServeResult.SafeHeaders carries no Retry-After to relay, and S15's requirement is what authorizes the substitute. writeRemoteProxyEnvelope's doc and rpWantRetryAfter's carried the same two-group split and are corrected with it. |
| Second maintainer pass: the Maven divergence was moved onto the wrong Maven arm | Fixed, and the reviewer's reading of Maven is right. maven.mapFetchErrorStatus fills UpstreamRetryAfter only inside case status >= 500 && status < 600, so the preference lives on the relayed 503; maven.cacheFallbackOrUnavailable leaves it empty and expresses no preference for a health verdict. The sentence names the relayed 503 now, and says explicitly that the two mappings agree on the verdict arms. |
| Second maintainer pass: the coalescing constant cites S13 and not the S15 statement it diverges from | Fixed in the comment and tracked: #785 (closed) carries the S15 edit, for the reason the reviewer gave, that the other two spec gaps this branch found have owners and this one had none. No spec hunk lands here. |
| Second maintainer pass: the wait-timeout subtest's rationale named a field that does not set the coalescing window | Fixed. RemoteDistTagsHandlerDeps.SingleFlightWaitTimeout reaches responseBudget only, and the reviewer is right that the deadline is unobservable here, since SetWriteDeadline on an httptest.ResponseRecorder answers "feature not supported" and the handler logs a WARN and continues. The comment now says what the subtest actually pins and where the window comes from, and keeps the reason the reviewer confirmed: without it, that override arm has no caller. |
Second maintainer pass: the dist-tags 503 boot subtest reached the upstream and asserted no path |
Closed rather than narrowed. The subtest now asserts doer.Calls() and the upstream path, so the doc comment's standing rule is true again and a later author copying this subtest inherits the assertion. The matching sentence in The mount above is updated. |
📐 Governing ADRs
- ADR-009 API design: conforms. The relayed non-
4044xxfollowsapi-style.md's propagation rule, and no upstream header travels. - ADR-008 Content-addressable storage: conforms. The strong
ETagis derived from the served blob's content address, not the storedupstream_etag. - ADR-005 Artifact delivery mode: partial, pre-existing deviation. The cache serve honours the instance default, but no npm read path threads the per-namespace override ADR-005 requires be "always available":
npm.Resolutioncarries no field for one, whilemaven.Resolution.DeliveryModeOverridedoes. Inherited from the hosted npm reads and now fromserveRemoteCached, not widened here. Tracked in #708 (closed). - Mirror is STALE.
./scripts/adr-freshness.shreports two upstream commits not yet synced, touching021_authorization.mdand012_usage_data_collection.md. Both were read upstream rather than in the mirror. ADR-021's change puts reading an npm dist-tag underread_artifactand scopes the write verbs tocreate_artifactanddelete_artifact; this route is a read, authorization is enforced by middleware ahead of the handler, and no write verb is mounted, so the change reinforces rather than conflicts. ADR-012 concerns the dimensions a billable event carries; this handler takes no usage-tracker seam and emits no usage event, so it is not reached. One internal ADR is never mirrored and was not read.
✅ Testing
Run against this branch's content, on a real PostgreSQL.
go test ./internal/format/npm/...: pass.go test -tags=integration ./internal/format/npm/: pass. The dist-tags suite is 36 top-level test functions, 27 in the integration file and 9 in the internal one. An earlier revision of this description said 24, which under-reported it even then.go test -tags=integration ./cmd/artifact-registry/ -run TestMountSlugAnchoredFormats_NpmRemote: pass. Three top-level tests now: the two per-route mounts with four subtests each, and the shared-registry composition test, which has none. Also run in full, without-run, against a tree withbuildNpmRemoteSlotssplit into twobuildNpmRemoteOperationscalls: the composition test is the only failure in the package.golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=falseover./internal/format/npm/and./cmd/artifact-registry/: zero findings on the files this MR touches. Twogovet unusedwritefindings that theRetry-Aftersignature change introduced inremote_read_errors_test.goare fixed rather than suppressed..claude/skills/run-artifact-registry/driver.sh smoke:41 passed, 0 failed, from the run after the rebase onto7f8068df. Not re-run for the review-feedback commit, which changes comments and tests only and leaves the built binary identical. The new dist-tags check answers500in ephemeral mode, the same status the packument check expects there and for the same reason, which is what shows the slot is mounted rather than on501.
📏 Diff size
3,095 reviewable LOC against the 500-line ceiling in docs/dev/development-model.md, which counts test files and excludes only vendored, generated, and binary ones. The ceiling is exceeded, so here is why splitting would not help: 537 added lines are production Go, 2,523 are the suites that pin them, and 33 are the run recipe and the e2e catalog. Separating the suites from the code they constrain would leave each MR unreviewable against the other, and the production half is one new file mirroring the already-reviewed packument route plus edits to shared code and one composition-root slot. Of the 537, 132 are comment lines in remote_read_errors.go rather than statements.
| File | Added | Removed |
|---|---|---|
internal/format/npm/remote_disttags_integration_test.go |
1,689 | 0 |
internal/format/npm/remote_disttags_internal_test.go |
392 | 0 |
internal/format/npm/remote_disttags.go |
328 | 0 |
cmd/artifact-registry/wire_npm_remote_boot_integration_test.go |
311 | 25 |
internal/format/npm/remote_read_errors.go |
132 | 23 |
internal/format/npm/remote_read_errors_test.go |
107 | 19 |
internal/format/npm/remote_document_serve.go |
37 | 6 |
cmd/artifact-registry/wire_npm_remote.go |
28 | 2 |
.claude/skills/run-artifact-registry/SKILL.md |
15 | 10 |
internal/format/npm/remote_packument_harness_integration_test.go |
16 | 0 |
internal/format/npm/remote_packument.go |
10 | 5 |
.claude/skills/run-artifact-registry/driver.sh |
9 | 0 |
docs/testing/e2e/npm.md |
9 | 6 |
internal/format/npm/remote_document_serve_test.go |
4 | 0 |
internal/format/npm/remote_packument_errors_integration_test.go |
4 | 2 |
internal/format/npm/remote_packument_internal_test.go |
2 | 0 |
internal/remote/standalone.go |
2 | 1 |
The integration suite builds on the merged rp harness in remote_packument_harness_integration_test.go rather than seeding a second one, which is where the earlier revision's 830 lines of fixture went.
The third commit adds roughly 590 of the reviewable lines. Around 160 of those are the two subtests carried over from the mirrored packument suite plus the reserved-name row, which were raised in review as coverage the first commit dropped.
The fourth commit adds 259 and removes 54, all of it review feedback. 171 net lines are the shared-registry composition test and the boot suite's helper changes that make it possible; the rest is comment corrections and one assertion. The fifth is a six-line comment fix on top of it.
🧪 E2E scenario impact
The catalog changes, in three ways. An earlier revision of this description said no scenario was added or affected, which was true only while the route stayed on 501.
Two prose claims are now false and are corrected. The scope note said the dist-tags route "is not mounted yet", and the ## Remote repositories section said "Only the packument read is mounted".
One scenario is added: e2e.npm.remote.dist-tags-read. This departs from the plan's e2e bullet, which reasons that a pkg@tag install resolves the tag client-side from the packument, so no npm install journey calls this endpoint. That reasoning is correct and I am not disputing it, but npm dist-tag ls calls the endpoint directly, and the catalog already carries that journey for hosted repositories as e2e.npm.discover.list-dist-tags. So the mount does open a real client journey, and the bullet's scope was npm install rather than the whole catalog. Happy to drop the row if the plan's reading should stand.
The two shared-code changes reach the mounted packument read and open no journey: nosniff is a response header on an existing scenario's answer, and the coalescing 503, whatever its Retry-After, is a failure mode no catalogued scenario drives.
Related to #348 (closed)