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 500 arms 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 silent 500.
  • Redaction and non-caching hold on every arm. Every record goes through remote.RedactedForLog and every envelope carries a pre-approved detail; every response is no-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 main books 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 500 rows 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)

Edited by Moaz Khalifa

Merge request reports

Loading
Loading