docs(specs): declare resolve_timeout on S13's resolution block
What
Declares virtual_repositories.resolution.resolve_timeout on the block S13 owns, adds the outcome an expiry needs, and rules on what the bound covers. Two specs, no code.
S13 gains the proto field (5), the YAML line, the ## Configuration block stating the bound and deriving its 90s default, five acceptance criteria, a ## Resolutions entry recording the ruling, and the ResolveBudgetExpired outcome. ResolverCancelled is narrowed to the caller's own context, the two Retry-After enumerations and the Error Cases fixture table gain the new outcome, and the Retry-After: 1 rationale gains its reason.
S31 has its No aggregate deadline of its own ruling reversed, its ## Resolutions configuration entry corrected, an Error Cases row for budget expiry, and a budget_expired value on the resolution_outcome enum so the new outcome is representable in the wide event and its metric.
Why
S13's resolution knobs bound one leg each. probe_timeout bounds a single HEAD and not the sweep, and a Phase 3 follower waits up to single_flight_wait_timeout, whose default is minutes. They compose without a ceiling, and nothing on the server side supplies one: no read handler sets a request deadline and the tree has no http.TimeoutHandler. A client that brings no deadline of its own holds a virtual read for as long as the phases take.
The docs/plans/2026-08-21-s31-npm-virtual.md ## Dependencies section prescribes this MR: "the one spec amendment that opens before the step it serves", with the key landing in S13's shared block because the bound is format-agnostic and S30 and S32 need the same one. It gates #893 (closed), whose branch is complete and green but cannot open its MR until a spec declares the field.
Two judgement calls worth a reviewer's attention
ResolveBudgetExpired is a new outcome, in the Infrastructure class. ResolverCancelled covered "the client disconnected or its deadline expired" and carries "the caller needs no response". That is right for a caller who has gone and wrong for a bound the server set while the client is still waiting, where writing nothing reaches the client as a transport failure rather than an answer. The new outcome sits beside SingleFlightTimeout, its nearest analogue in class, status and Retry-After, rather than under Abandonment, because the caller's request has not ended.
The bound covers the decision only, and it is the resolver's. The budget is armed inside the resolver and released at winner selection. Three legs sit outside it: the winner's artifact-body transfer, a single-flight follower's wait for its leader's commit, and a warm cache serve the caller is still reading. A cold fill still streaming at expiry therefore completes and writes its cache row.
This is the question docs/plans/2026-08-21-s31-npm-virtual.md left for this amendment ("Whether truncation is the intended total-transfer bound is for the spec amendment to rule on"), and the ruling matches the one already recorded in docs/plans/2026-08-27-s32-container-virtual.md. The reasoning: cover 12 exempts an artifact-body Fetch from request_total_timeout so a cache fill is not cut by a flat whole-request cap, and a budget that bounded the transfer would put that cap straight back on every virtual read, leaving any layer bigger than the budget permanently uncacheable. It would also cap single_flight_wait_timeout at its own value, since a follower's wait runs inside resolution.
This fixes where the deadline is armed, which an earlier revision of this MR left open. A facade-side budget cannot satisfy the follower criterion: a follower has no first byte of its own, so no caller can stop the timer before its wait. #893 (closed)'s facade arming needs to move into the resolver; docs/plans/2026-08-27-s32-container-virtual.md already designs it there.
The default is 90s, not 40s. With the transfer outside the budget the remaining term is Phase 1 plus Phase 2. At ADR-004's 20-upstream cap and probe_concurrency 10 that is two waves: 2 * 5s = 10s for npm and Maven, and 2 * (10s + 5s + 10s + 5s) = 60s for container, whose probe can pay a token exchange before its HEAD and a fresh one on a 401. 90s clears the 60s worst case rather than matching it. Every input was re-read from internal/config/virtualrepositories.go and internal/config/container_remote.go rather than copied.
What is deliberately not here
S31's "S31 introduces no configuration" sentence is untouched, and stays true as written: the field is S13's, so S31 still adds none. The plan's Dependencies bullet anticipated reversing it because it assumed Step 12 would add the proto field. Landing the key on S13 instead means only the ## Resolutions premise needed correcting, not the sentence.
S32's spec is untouched. docs/specs/S32-container-virtual.md carries the same "No aggregate deadline of its own" paragraph and the same Abandonment row, and both go stale when this merges. Three MRs are already open in that file (!2240, !2127 (closed), !2126 (closed)), so widening this one into it conflicts whichever lands second. Tracked as a follow-up, and S32's own plan already designs the budget verdict.
Conflict note
!2169 (merged) also edits docs/specs/S13-virtual-remote-foundation.md, in three of the same regions. Its edits are all about health_check.scheduled_interval and its hunks are 513, 787, 842 and 1122. Two will need a trivial resolution whichever lands second:
- the proto snippet, where it changes
HealthCheckConfig.scheduled_interval's comment one line below theResolutionConfigfield this MR adds; - the second
Retry-Afterenumeration, where it rewords the sentence this MR extends.
The ## Configuration prose does not collide: its paragraph is rewritten in place and this MR appends new paragraphs after the single_flight_wait_timeout sizing paragraph.
Companion plan MR
!2250 (merged) aligns docs/plans/2026-08-21-s31-npm-virtual.md with this change: Step 12's Files list drops the four configuration surfaces, the default's derivation moves here, and the ## Dependencies bullet is corrected where it expected this amendment to reverse S31's "introduces no configuration" sentence. The two are independent rather than stacked. Both are cut from main and touch disjoint files, so a merge in either order is correct.
!2250 (merged) needs one more edit before it merges. It was written when the default was 40s and the transfer counted against the budget. This MR rules the transfer out and sizes the default at 90s, so Step 12's figure and the paragraphs deriving it are stale as they stand.
Follow-on work, in merge order
- This MR, with !2250 (merged) alongside it.
- #1069 (closed), the S13 code MR for the proto field, the loader default and validation,
config.example.yamland the configuration reference, with the five loader tests. That work is already written and green on #893 (closed)'s branch and moves out of it wholesale. - #893 (closed)'s own MR, which then declares no configuration and keeps only the deadline mechanism and its own non-positive-duration guard. Its arming point moves. #893 (closed) arms the budget in the facade around
Resolve; the ruling here puts it inside the resolver, released at winner selection, because a facade-held budget cannot keep a single-flight follower's wait outside it. That rework lands in #893 (closed), not here, and this MR must merge first so the criteria it is built against exist.
No code reads the key yet, so nothing ships broken in the window between this MR and #1069 (closed): the field is declared in the spec here and added to the proto and loader there. What this MR does move ahead of its code is the set of acceptance criteria in ### Resolution edge cases, which #893 (closed) is the MR that satisfies.
Related to #893 (closed)