feat(npm): the virtual resolution facade 3/3 (S31 plan: 12/19)
The stack
Step 12 was cut into three MRs, and the first two have merged. Part 3 is this
MR: its Resolve calls part 1's virtualFetchOptions and its composed suite
asserts envelopes through part 2's mapping, so it could not compile on main
without both. With both merged it targets main directly.
| Order | MR | Part | Contents | LOC at the cut | State |
|---|---|---|---|---|---|
| 1 | !2325 (merged) | 1/3 | the read's remote.FetchOptions |
106 | merged |
| 2 | !2326 (merged) | 2/3 | the error mapping and its taxonomy | 1574 | merged |
| 3 | !2255 (merged) | 3/3 | the resolution facade and the composed suite | 1762 | this MR |
3442 LOC across the three at the cut, which was the whole of what !2255 (merged) carried before the split: nothing was added or dropped, and the three parts together reproduced the reviewed head file for file. Review has moved the tree on since then, so Reviewable LOC below is re-derived at this MR's current head rather than carried forward from that table.
What this MR does
The one call a kind=virtual npm read makes: assemble the upstream list, run
virtual.Resolver.Resolve over it, and hand back the outcome alongside the
position list its winner is named from. Part 3 of the three this step was split
into, and the one that composes the other two.
Rationale lives in virtual_operations.md; this description covers what a
reviewer needs before opening it.
Why the facade returns more than virtual.ResolveResult
ResolveResult names an index into the list Resolve ran over. Two
consumers need more than that and neither is in the tree yet: a metadata serve
needs the winning upstream's repository_id, because a virtual repository owns
no npm rows of its own, and the resolution wide event needs that id, the kind,
and the stored position column. Only the assembled position list turns an
index into any of the three, so VirtualReadResult carries both and Winner
does the lookup. Returning the bare result would push every consumer into
re-indexing the caller's own slice and leave winner_position ambiguous between
an index and a column.
Winner reports false for every outcome that reached none. It tests for the
two defined outcomes rather than against ResolveUnknown, because
VirtualReadResult is exported: a declarable out-of-range ResolveOutcome
selects no payload field, and a not-equal test would still hand back
Positions[0] as a winner. Positions travel with a resolver failure too, so a
record can name the upstream that refused; WinnerFetchFailedError carries only
a status. An assembly failure returns the zero value, because no list was
assembled to name.
The two construction guards
NewVirtualResolution refuses an unset npm.public_registry_url and a
non-positive npm.max_remote_packument_size. Both are knobs whose unset value
is silently usable rather than an error, which is what makes a constructor
guard the only place to catch them:
packumentBaseURLover an empty base returns a non-empty host-relative path, so the deployment serves relativedist.tarballvalues instead of failing.remote.FetchOptionsreads a zeroMaxBodyBytesas "use the selected kind's default", so the configured npm override is swapped for the smaller metadata-class cap and a large-but-valid packument fails on a virtual read while succeeding through akind=remoterepository.NewRemotePackumentHandlerpanics on the same value.
The registry URL is read off the assembly the facade is handed
(VirtualUpstreamAssemblyDeps.PublicRegistryURL), never from a copy of the
facade's own. Two fields, each checked for emptiness on its own, would let two
non-empty but different URLs pass both constructors: the fill-time rewrite
would bake the assembly's host into every cached dist.tarball value while the
facade had validated the other, and nothing downstream compares them.
A zero value and a nil receiver each name their own defect rather than panicking.
The document variant is chosen once
The foundation is kind-keyed while a kind-agnostic implementation passes almost
every other test: it would answer an abbreviated Accept from a fresh full-kind
row, or a full Accept from an abbreviated one, and serve the wrong
Content-Type on the primary read path. VirtualPackumentTarget negotiates
through remotePackumentVariant, the wrapper over negotiatePackumentKind the
standalone remote route also calls. VirtualDistTagsTarget needs no
negotiation: dist-tags is a document in its own right on both upstream kinds.
Reachability
Nothing here reaches a route. NewVirtualResolution, VirtualPackumentTarget
and VirtualDistTagsTarget have no non-test caller, and all three virtual read
slots in handler.go are still on the interim 501, so nothing in this MR is
production-reachable.
Reviewable LOC
1813 reviewable LOC (1801 added, 12 removed), measured at d10cfb516 against
main at bb5133f24, over the 500 ceiling in
development-model.md.
| Group | Files | LOC |
|---|---|---|
Production Go (virtual_operations.go 174, virtual_assembly.go 5, virtual_fetch_options.go 2) |
3 | 181 |
Tests (virtual_operations_test.go 1109, virtual_operations_internal_test.go 228, virtual_fill_abandonment_test.go 117, virtual_assembly_test.go 7, virtual_fetch_options_test.go 6) |
5 | 1467 |
Sidecar docs (virtual_operations.md 140, virtual_read_errors.md 25) |
2 | 165 |
Production is 181. The overrun is the composed suite, and splitting it further
is what the three-way cut already did: the remaining 1467 is one fixture driving
the assembly, the resolver and the mapping end to end, and cutting it again
would separate cases from the fixture they share. The single largest case group,
TestVirtualResolution_VerdictsReachTheClientAsTheirEnvelope, is what proves a
verdict reaches a client as the right envelope, which no unit-level test in
parts 1 or 2 can show.
Spec coverage
Spec: docs/specs/S31-npm-virtual.md Plan: docs/plans/2026-08-21-s31-npm-virtual.md, Step 12
Acceptance criteria: Resolution
| # | Criterion | Tests |
|---|---|---|
| R-4 | Warm read short-circuits | Step 11 TestVirtualUpstreamAssembly_LowestEligibleHitWins; end to end at Step 15. |
| R-6 | No winner: 404 package_not_found |
TestVirtualResolution_VerdictsReachTheClientAsTheirEnvelope |
| R-7 | No upstreams configured: 404 |
Same test |
| R-8 | All eligible remotes unhealthy: 503 with Retry-After, no probe |
Same test, with wantNoUpstreamRequests |
| R-9 | A hosted miss alongside an unhealthy remote is a 404 |
Same test, a_hosted_miss_beside_an_unhealthy_remote_is_a_404 |
| R-11 | Outage is not an absence: 503, never 404 |
Same test |
| R-12 | Cache fallback serves a non-winning position | TestVirtualResolution_CacheFallbackServesANonWinningPosition |
| R-13 | Winner Fetch failure does not fall through | TestVirtualResolution_AFailedResolutionStillNamesItsPositions; the mapping half is part 2's |
| R-14 | An upstream of an unexpected kind fails closed | TestVirtualResolution_AnUnexpectedKindFailsClosed |
| R-1 to R-3, R-5, R-15 to R-19 | Steps 4, 5, 11, 15, 17, 19. Not in this MR. |
Acceptance criteria: Packument re-point
| # | Criterion | Tests |
|---|---|---|
| P-1, P-5, P-7 | Remote winner re-points to the virtual repository; the winner's cache is untouched; integrity fields relayed | TestVirtualResolution_FillRewritesOntoTheWinningPositionsOwnBase (fill pass; the serve pass is Step 13) |
| P-8 | Abbreviated variant | TestVirtualPackumentTarget_AcceptSelectsTheVariantOnce, TestVirtualResolution_MetadataReadCarriesTheConfiguredPackumentCap |
| P-9 | dist-tags served unchanged | TestVirtualDistTagsTarget_IsADocumentOfItsOwn; the serve is Step 16. |
| P-2 to P-4, P-6, P-10, P-11 | Steps 13 and 19. Not in this MR. |
Allow/deny and validation
| # | Criterion | Tests |
|---|---|---|
| A-1 | Denial re-routes to the next upstream, audit event emitted | TestVirtualResolution_ADeniedPositionLetsALowerOneServe |
| A-8 to A-10 | Corrupt rules fail closed on both request kinds | TestVirtualResolution_ACorruptRuleFailsClosedOnEveryRequestKind |
| A-11, A-12 | Every upstream ineligible for a data error: 500 |
TestVirtualResolution_VerdictsReachTheClientAsTheirEnvelope, TestVirtualResolution_AnUnexpectedKindFailsClosed |
| E-13 | A coalesced follower outwaited its leader | TestVirtualResolution_ASynchronousFillAbandonmentIsRetryable, driving a real parked follower rather than a hand-built error |
Beyond the spec's own table
| Claim | Tests |
|---|---|
| Fetch options reach the resolver per target | TestVirtualResolution_TarballReadSelectsTheBlobSizeCapClass, TestVirtualResolution_MetadataReadCarriesTheConfiguredPackumentCap, TestVirtualResolution_RequestGlobalOptionsCarryNoRewriter |
| Every position binds to the assembled path, never a second derivation | TestVirtualResolution_BindsEveryPositionToTheAssembledPath |
| The winner is named by its stored row, not its index | TestVirtualResolution_NamesTheWinningPositionsOwnRow (positions 0 and 5), TestVirtualResolution_NamesNoWinnerWhenTheResolutionReachedNone |
| Both construction guards, the zero value and the nil receiver | TestNewVirtualResolution_RefusesAnUnusableComposition (five spoiled compositions), TestNewVirtualResolution_AcceptsAWiredComposition, TestVirtualResolution_RefusesAnUnsetPublicRegistryURL, TestVirtualResolution_AZeroValueReportsItsOwnDefect, TestVirtualResolution_ANilReceiverReportsItsOwnDefect |
Winner names only the two defined outcomes and only an in-range position |
TestVirtualReadResult_WinnerRefusesAnUndefinedOutcome, TestVirtualReadResult_WinnerNamesBothDefinedOutcomes, TestVirtualReadResult_WinnerRefusesAPositionOutsideItsList |
| An assembly failure reaches the caller as itself | TestVirtualResolution_RelaysAnAssemblyFailure |
Security considerations
| # | Concern | Tests |
|---|---|---|
| SEC-4 | The recursion guard is defended twice | TestVirtualResolution_AnUnexpectedKindFailsClosed (resolution-time half; write-time half is S17) |
| SEC-5 | The re-point keeps pulls inside the registry | Fill-time half: TestVirtualResolution_FillRewritesOntoTheWinningPositionsOwnBase. Serve-time half is Step 13. |
| SEC-2, SEC-3 | Authorization; composition widening | Step 10, merged, and S17. Not in this MR. |
What this step leaves open
served_from_fallback has no test here and cannot get one from what this step
returns. virtual.Resolver answers a fallback serve with the same
ResolveServeFromCache outcome a winner's own cache serve returns, and
VirtualPosition carries no per-position outcome, so nothing downstream can
tell the two apart. TestVirtualResolution_CacheFallbackServesANonWinningPosition
pins that a non-winning position's entry is what answers, and that the
highest-priority entry wins when several hold one, which is the behavioural
half. Naming the outcome needs a discriminator this step does not add; recorded
on #893 (closed).
Two deviations from the plan, stated so a reader cross-referencing it can tell a
correction from an oversight. The plan's Files entry says the 500 arm writes
through writeRemoteProxyServerError; it writes through
writeVirtualServerError, for the double-record reason part 2's description
gives. And the plan names two inline-build codes where the mapping covers three.
e2e scenarios
No scenario in docs/testing/e2e/npm.md is added or
affected. That catalog's ## Remote repositories section covers kind=remote
and it carries no virtual section: no route reaches this code, so no end-to-end
flow can exercise it. The virtual npm scenarios land with the serve surface and
the three read handlers (Steps 13, 15 to 17), and the round trip is Step 19.
Conformance
No format-handler behaviour changes: internal/handlers/{oci,maven,npm}/ is
untouched and no route is registered, so the npm conformance harness has nothing
new to exercise.
Related to #893 (closed)