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:

  • packumentBaseURL over an empty base returns a non-empty host-relative path, so the deployment serves relative dist.tarball values instead of failing.
  • remote.FetchOptions reads a zero MaxBodyBytes as "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 a kind=remote repository. NewRemotePackumentHandler panics 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)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading