fix(virtual): a mixed absence answers the outage, not the not-found

Summary

CollectVerdict answered remote.ErrFileNotFoundOnUpstreams, a 404, for a record set where one position established an absence and a consulted position beside it produced nothing. All three specs require remote.ErrUpstreamUnavailable, a 503, for that set. This narrows the arm.

docs/specs/S13-virtual-remote-foundation.md:389-390 is the ruling:

An absence established at every consulted position returns FileNotFoundOnUpstreams. Anything short of that returns UpstreamUnavailable: no absence at all, and the mixed case where some positions established an absence and others failed on transport.

The Error Cases rows say the same in three places: S13:1163, S30-maven-virtual.md:691 and S31-npm-virtual.md:1721. The spec side was settled by !2699 (merged); only the code was behind.

flags.absent was set by any PositionAbsent record and read with flags.degraded unconsulted, so three sets answered the 404: an absence paired with PositionOmitted, with PositionSlow, or with PositionPanicked. Each of those failures is a consulted position that established no absence, so each set is short of an absence at every consulted position.

Why it matters: npm stores a 404 as a hard E404 and never retries it. One failed upstream beside one absent upstream could leave a published package permanently missing for every client that asked during the window, and a rollback does not repair it because the answer is cached on the clients. The 503 carries Retry-After instead.

What changed

The arm is now two. An absence at every consulted position keeps the not-found; the mixed set answers errAbsenceShortOfEveryPosition.

A fourth sentinel rather than the reword of errNothingContributed the issue's scope suggested. That value names a set where no position established an absence at all, and the mixed set is not that set. The comment over the block says each arm carries its own value "so a 503's log line names the arm that fired", and TestCollectVerdict_TheOutageArmsAreDistinguishable asserts exactly that, so folding the two reddens it. errNothingContributed's own message is therefore unchanged and still accurate.

Two shapes deliberately keep the 404, and both are pinned: a lone absence, and an absence beside a policy denial. A denial moves no flag, an ineligibility is taken out by the arm above, and PositionDataError cannot reach either arm because it sets unservedPresence too.

Governing ADRs

Checked rather than answered by default. The mirror is stale: scripts/adr-freshness.sh reports one unsynced upstream commit, an ADR-007 amendment on the maven_files package-id index. That file is not reached here, so the local mirror is adequate for this change, and no other decision file is behind.

ADR-009 API design is the one that reaches this surface, and this change conforms. It governs which status a virtual read answers, but its virtual 404 rulings are all route-level: tags/list and referrers/:digest at :275-281, the npm tarball and per-version reads at :332-344. This change is a verdict inside internal/virtual, a seam no route composes, and it does not alter any route's mapping.

One interaction worth naming rather than burying. ADR-009:277 records that crane reads no error code and falls back on a 404 while treating other statuses as errors. This change moves more collection sets from 404 to 503, which widens that concern for container referrers. That is the subject of #1390, an open, unassigned request to admit a 404 as a collection-mode verdict for referrers, and it is the S13 DRI's call. It is not a blocker here: #1390 asks for a slice-level override, a different mechanism from this arm, and no route composes Collect today.

No other ADR is reached. ADR-004's limits are untouched: no count, size or timeout moves.

Testing

Test-first: the test(...) commit lands the assertions red, the fix(...) commit turns them green. That is the one --no-verify this branch uses, per guardrail 10.

The issue's scope list undercounted the sites. It named the three subtests of one test. Two more pinned the losing side:

  • the TestCollectVerdict_FailureArms row for [absent, omitted]
  • the second half of TestCollectVerdict_AnUnservedPresenceWithholdsTheNotFound, whose [absent, omitted] half stops discriminating once both sets answer one sentinel. It now pins the arm by its message instead.

Also renamed TestCollectVerdict_AnAbsenceStandsBesideAContributorSideFailure, whose name read the other way round from what it now asserts, and added a row for an absence at every consulted position so the positive side stays covered.

Mutation checked, three for three. Each of these reddens the suite:

Mutant Result
drop the !flags.degraded narrowing reddens
reuse errNothingContributed for the mixed set reddens
let the mixed arm answer the not-found reddens

internal/virtual, internal/config and the full internal/format/npm suites pass, golangci-lint reports 0 issues, and the comment-caps gate passes against origin/main.

e2e scenario impact: none. No route composes Collect, so no docs/testing/ scenario reaches this verdict. The rows that will cover it belong to the consuming slices.

docs/user/** impact: none. No customer-visible surface changes, and no configuration key moves.

Merge order

This must merge before !2745 (merged) (S13 collection mode step 6). The collection-mode plan states the gate at docs/plans/2026-09-04-virtual-collection-mode.md:2779-2781:

The correction is gated, not only owed: before this plan's own Step 6, and in any case before S31's Step 13 part 2.

!2745 (merged) is step 6, and it is what first makes PositionSlow producible, so it makes [PositionAbsent, PositionSlow] reachable. That ordering is raised on !2745 (merged) in an escalate: note.

One overlap to note: this branch edits ErrPositionAbsent's doc comment in internal/virtual/collect.go, and !2745 (merged) rewords the same comment for its own reason. The two edits do not conflict in substance, but the same lines move on both branches, so !2745 (merged) rebases onto this.

Closes #1382 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading