feat(maven): map the virtual read errors into the Maven envelopes (S30 plan: 11a/25)
Summary
The one place a failed kind=virtual Maven read becomes the Maven envelope: writeMavenVirtualReadError maps the resolution error surface onto a status, code, and Retry-After — the S01 JSON envelope for a read-path 404, RFC 9457 problem+json for every other non-success.
Three things this MR establishes:
- One arm per value, ordered deliberately. Abandonment runs first, because a departed caller's error can carry a second sentinel; the winner relay runs before the taxonomy, because it answers about the upstream's exchange rather than about this service; and the named
500arms run ahead of the fold-through, so the fall-through record stays the one signal that the taxonomy named nothing. - The derivation is executable, not transcribed. Six rows are derived from
remote.FallbackEligible's exclusion arm by a source-reading test, so a sentinel added to that predicate fails the suite instead of shipping a silent500. - Redaction and non-caching hold on every arm. Every record goes through
remote.RedactedForLogand every envelope carries a pre-approved detail; every response isno-store.
The winner relay carries the upstream's own wait
S30's Error Cases row for the winner's Phase 3 failure relays the upstream's own Retry-After where the response carried one, and adds none where it did not. That value was being dropped one layer down: remote.WinnerFetchFailedError carried the status alone. This step adds the field, fills it in fetchWinner from the FetchResult the mapping cannot see, and relays it on a 503 or 429 held to the same ceiling the kind=remote route applies. The substituted-status case (204, an unsolicited 304, a refused coding) is unchanged and takes the health-sweep value, because that 503 is this mapping's own verdict rather than a relay.
Scope
One step of 25 in the S30 plan (Step 11a). It wires nothing: writeMavenVirtualReadError has no production caller yet — Steps 11b, 13, 17 and 18 compose and call it — so no route serves a virtual read as of this commit.
Depends on
- The plan-correction MR (!2891 (merged)) — required merge order. It books this step's files, four-sentinel count, armed-budget wording and scoped acceptance clause. Until it lands, the plan on
mainbooks three paths and describes the budget as unarmed. - Step 10c (!2862 (merged)), this MR's target branch: the assembly, the fail-closed gates, and the wrong-format mark whose verdicts this mapping's
500rows answer.
Spec coverage
Spec: docs/specs/S30-maven-virtual.md (### Error Cases, ### Acceptance Criteria). Plan: docs/plans/2026-09-10-s30-maven-virtual.md, ### Step 11a.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| Resolution 11/12 | No winner, and no upstreams configured, return 404, code: "not_found" |
TestMavenVirtualReadErrors_ReadPathNotFoundsAreIndistinguishable |
| Resolution 14 | Every rule-eligible position unhealthy returns 503 with Retry-After and no probe |
TestMavenVirtualReadErrors_HealthVerdictsTakeThePolicyValue, TestMavenVirtualReadErrors_Every503InTheTableCarriesRetryAfter |
| Resolution 16 | Outage is not an absence: 503, never 404 |
TestMavenVirtualReadErrors_NoValueOutsideTheAnswerableTerminalClassIs404, TestMavenVirtualReadErrors_MapsEveryTaxonomyValue |
| Resolution 20 | Winner Fetch failure maps the upstream's own status, and a relayed 503/429 carries the upstream's own wait |
TestMavenVirtualReadErrors_WinnerFetchFailedRelaysTheUpstreamStatus, TestResolve_WinnerFetchNon2xx_CarriesTheUpstreamRetryAfter |
| Resolution 22/23/24 | A wrong-format or unexpected-kind row answers 500 rather than the 404 its miss would establish |
TestMavenVirtualReadErrors_MapsEveryTaxonomyValue (ErrAllUpstreamsIneligible), TestMavenVirtualReadErrors_ANamedServerErrorEarnsOneMavenRecord |
| Resolution 30 | The shared budget answers 503 with Retry-After: 1, and is not an abandonment |
TestMavenVirtualReadErrors_ABudgetCutBesideAnUnavailableVerdictKeepsTheBudgetRow, TestMavenVirtualReadErrors_ABudgetExpiryIsNotAnUpstreamOutage, TestMavenVirtualReadErrors_ADepartedCallerIsStampedNotLeftAt200 |
| Validation 5 | Read-path 404 shape: the S01 JSON envelope on GET and HEAD |
TestMavenVirtualReadErrors_ReadPath404ShapeHoldsOnHEAD, TestMavenVirtualReadErrors_ReadPathNotFoundsAreIndistinguishable |
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Absence verdicts: NoUpstreams, AllUpstreamsDenied, FileNotFoundOnUpstreams |
TestMavenVirtualReadErrors_ReadPathNotFoundsAreIndistinguishable |
| E-2 | Winner's FetchFailed, relayed through mapFetchErrorStatus |
TestMavenVirtualReadErrors_WinnerFetchFailedRelaysTheUpstreamStatus |
| E-3 | Coalescing trio: SingleFlightTimeout, LeaderCanceled, FillAbandoned |
TestMavenVirtualReadErrors_CoalescingRowsShareOneRetryAfter |
| E-4 | Budget expiry, under its own record | TestMavenVirtualReadErrors_ABudgetExpiryIsNotAnUpstreamOutage |
| E-5 | Staging refusal, under the publish shed's jittered window | TestMavenVirtualReadErrors_StagingShedIsUpstreamUnavailable, TestMavenVirtualReadErrors_StagingShedJittersItsRetryAfter |
| E-6 | Infrastructure failures and the wiring sentinels answer 500 |
TestMavenVirtualReadErrors_ANamedServerErrorEarnsOneMavenRecord, TestMavenVirtualReadErrors_DefaultArmIsReachedOnlyByAWrappedDatastoreError |
| E-7 | Abandonment writes no envelope; a departed caller is stamped, never left at 200 | TestMavenVirtualReadErrors_ADepartedCallerIsStampedNotLeftAt200, TestMavenVirtualReadErrors_AbandonmentOutranksASecondSentinel, TestMavenVirtualReadErrors_ADepartedCallerDuringTheAssemblyIsNotA500 |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | No credential reaches a log line or an envelope on any arm | TestMavenVirtualReadErrors_LogsNoCredentialOnAnyArm, TestMavenVirtualReadErrors_ServesNoCredentialOnAnyArm, TestMavenVirtualReadErrors_LogsNoCredentialOnTheAssemblyAbandonment |
| S-2 | A hostile upstream wait cannot park a client: the relayed value is clamped to the shared ceiling | TestMavenVirtualReadErrors_WinnerFetchFailedRelaysTheUpstreamStatus (the 86400 case) |
Tests
The suite mirrors npm's virtual_read_errors_test.go: every shared sentinel has a counterpart, and the npm-only rows (a route-supplied not-found code, the hosted inline-build rebuild) have no Maven surface. Locally: go test ./internal/format/maven/ ./internal/virtual/ ./internal/remote/ passes, golangci-lint run reports 0 issues, and the comment-cap gate passes.
No docs/testing/ scenario is added or affected: no route composes this mapper until Steps 17 and 18, so no conformance or e2e path reaches it. No docs/user/ surface changes: nothing a customer can call, see, or configure changes in this step.
Overlap with open MRs
Three open MRs touch files in this diff. Two share only whole files; one shares a paragraph:
| MR | Files shared | Hunk |
|---|---|---|
| !2882 (merged) (upload-session cap, 11/12) | docs/dev/storage.md, internal/format/npm/virtual_read_errors.md, internal/remote/errors.md |
same upload_session_admission_sheds_total paragraph in storage.md; different hunks in the other two |
| !2870 (merged) (negative cache, 3/3) | docs/specs/S30-maven-virtual.md, internal/virtual/resolve.go |
different hunks |
| !2836 (merged) (S32 OCI, 10b) | internal/virtual/resolve.go, internal/virtual/resolve_errors.md, internal/virtual/resolve_test.go |
different hunks |
The storage.md sentence this MR edits ("Maven's two arms have landed") sits inside the paragraph !2882 (merged) rewrites, so whichever lands second rebases that one row.
Size
1,991 insertions and 56 deletions across 14 files. By group: 285 production Go (virtual_read_errors.go 281, internal/remote/errors.go 3, internal/virtual/resolve.go 1), 1,397 test (virtual_read_errors_test.go 1,374, resolve_test.go 23), and 309 documentation and spec lines (virtual_read_errors.md 255, docs/specs/S30-maven-virtual.md 16, internal/remote/errors.md 15, and seven smaller edits).
Production source is under the 500-line ceiling. The documentation lines are this change's own falsification cost: four specs and three sidecars each carried a sentence the step makes untrue — the relay that never happened, the budget that is now armed, the second deriver, the third shed arm — and each edit is one sentence. Splitting them out would separate a spec from the behavior it just started describing.
Related to #289 (closed)