feat(maven): resolve a virtual read over its assembled upstream list (S30 plan: 11b/25)
Summary
The one call a virtual Maven read makes on the winner path: assemble the stored upstream list, run virtual.Resolver over it, and hand back the resolver's verdict with the position list index-parallel with the specs it ran over.
MavenVirtualResolution is a thin composition over Step 10c's MavenVirtualUpstreamAssembly and the shared resolver. It builds the request once, binds the assembled list to the resolution, and relays the resolver's verdict by identity, so the error taxonomy stays Step 11a's. Two things it owns beyond the relay:
- The dropped soft-deleted associations ride their own field. Step 10c drops those rows before any position is built, so they are not positions;
VirtualReadResult.SkippedSoftDeletedcarries them for Step 19b'sskipped_soft_deletedrecords, withPositionscounting live rows only. - An emptied list answers
remote.ErrNoUpstreams, and a body handed alongside that verdict is closed rather than leaked.
Scope
One step of 25 in the S30 plan (Step 11b). Library-level only: no route calls the facade yet, so no client-visible behavior changes. Steps 17 and 18 compose it into the winner-path and sidecar handlers.
Depends on
- Step 11a (!2892 (merged)), merged: the error mapping this facade's verdicts land in. This branch was stacked on it and now diffs against
mainwith only its own five files. - Step 10c (!2862 (merged)), merged: the assembly, its dropped-set field, and the shared test fixture this step extends.
Spec coverage
Spec: docs/specs/S30-maven-virtual.md (### Resolution, ### Allow/deny rules). Plan: docs/plans/2026-09-10-s30-maven-virtual.md, ### Step 11b.
Acceptance criteria
| Criterion | Test |
|---|---|
| Resolution: single winner on an artifact | TestMavenVirtualResolution_SingleWinnerOnAnArtifact |
| Resolution: hosted-and-remote mix | TestMavenVirtualResolution_HostedAndRemoteMix |
| Resolution: priority winner over a fresh hosted hit | TestMavenVirtualResolution_PriorityWinnerOverAFreshHostedHit |
| Resolution: warm read short-circuit | TestMavenVirtualResolution_WarmReadShortCircuits |
| Resolution: version-level metadata resolves a single winner | TestMavenVirtualResolution_VersionLevelMetadataResolvesSingleWinner |
| Resolution: an emptied list does not hand back a body | TestMavenVirtualResolution_AnEmptiedListClosesAForfeitedStream |
| Resolution: dropped associations reach the wide event's records | TestMavenVirtualResolution_NamesTheSkippedSoftDeletedAssociations |
| Allow/deny: a version rule governs that version's requests | TestMavenVirtualResolution_TheVersionRuleGovernsOnlyVersionLevelRequests |
| Allow/deny: group and artifact rules admit matches | TestMavenVirtualResolution_GroupAndArtifactRulesAdmitMatches |
| Allow/deny: a corrupt rule fails closed on the winner path, for both request levels | TestMavenVirtualResolution_ACorruptRuleFailsClosedOnBothRequestLevels |
| Allow/deny: a corrupted deny rule does not stop denying | TestMavenVirtualResolution_ACorruptedDenyRuleDoesNotStopDenying |
| Allow/deny: an allow and a deny on one pattern, the deny wins | TestMavenVirtualResolution_AnAllowAndADenyOnOnePatternIsDenied |
The corrupt target_field reaches the resolver's input directly: the column's CHECK rejects every insert of one, which is the point of having it.
Beyond the criteria: the request's ids reach the list read and the position list stays index-parallel (TestMavenVirtualResolution_BindsEveryPositionToTheAssembledPath), a failure still names its positions (TestMavenVirtualResolution_AFailedResolutionStillNamesItsPositions), the winner names its own row and stored position (TestMavenVirtualResolution_NamesTheWinningPositionsOwnRow, TestVirtualReadResult_Winner*), an assembly failure relays without resolving (TestMavenVirtualResolution_RelaysAnAssemblyFailure), the fetch options select the configured body-cap class per target (..._ArtifactReadSelectsTheBlobBodyCapClass, ..._MetadataReadSelectsTheMetadataBodyCapClass), and the constructor refuses an unusable composition (TestNewMavenVirtualResolution_RefusesAnUnusableComposition).
Governing ADRs
- ADR-001 / ADR-007 (namespace anchor, schema) — every seam is routed by the request's namespace, which the composite foreign key keeps shared with each upstream; the facade contributes no SQL.
- ADR-004 (limits) — no limit is minted here: body caps defer to the configured class caps, and the 20-upstream cap is write-time.
- ADR-005 (delivery mode) — no redirect/proxy decision is made; delivery rides the serve steps.
- ADR-008 (content-addressable storage) — fetches carry no transform and no second key derivation; the shared flight's cache store stays the only writer.
- ADR-009 (API design) — internal surface only; no route, envelope, or payload shape.
- ADR-012 (usage data) — no event is emitted here; the facade carries the dimensions Steps 17 and 19b emit with.
Tests
go test ./internal/format/maven/ passes; golangci-lint run reports 0 issues; the comment-cap gate passes. The suites mirror npm's virtual_operations* cases; the npm-only guard shapes that have no Maven counterpart are dropped with their reasons recorded in the sidecar and the plan.
Overlap with open MRs
No open MR touches this diff's five files (checked against all 68 open MRs). !2901 (merged) (Step 13, open) shares the package and edits virtual_assembly.go and virtual_assembly_test.go; its VirtualPosition additions are unexported and change nothing the facade reads. !2900 (merged) (S32 OCI) touches internal/format/oci/ only.
Size
1,691 insertions and 1 deletion across six files: 185 production Go (virtual_operations.go 180 and virtual_assembly.go 5), 113 sidecar documentation (virtual_operations.md), and 1,393 test (virtual_operations_test.go 1,115, the internal seam suite 256, and the shared fixture 22). Production source is far under the 500-line ceiling; the test bulk is npm's facade suite mirrored case-for-case, one case per acceptance bullet and one per rule direction. Splitting the facade would separate the request/result contract from the resolution that consumes it, and the tests cannot move to a later step.
e2e scenarios
No docs/testing/ scenario is added or affected: no route composes the facade yet, so no conformance or e2e path reaches it.
User documentation
No docs/user/ surface changes: nothing a customer can call, see, or configure changes in this step.
Related to #289 (closed)