docs(plans): add the shared internal/virtual collection mode plan

What this MR does

Adds the plan for the shared internal/virtual collection mode: the foundation seam that asks every eligible upstream for its document, records each position's outcome, and settles one aggregate verdict over those records.

Virtual repositories merge metadata across upstreams. The decision is !2018 (merged)'s, recorded at note 3774142678, which settles npm and Maven ("built once for both formats"); S32 carries the same shape for container separately.

Why this needs its own plan

Three specs describe the same foundation behavior, in nearly the same words, and none of them owns it:

Spec Its merge State
S30 Maven virtual maven-metadata.xml merged; books the seam in ## Dependencies and does not build it
S31 npm virtual packument and dist-tags amended by !2338 (merged), merged as c5ed5ef1d
S32 container virtual tags/list and referrers amendment open as !2240

#266's "Shared seam" item asks for it once rather than three times, and this is that plan. It gates real work: S31's Step 13 part 2 is the npm collection, and the S31 plan's ## Dependencies names this seam as a hard step-external gate on that step. It is not that step's only one — the same section gates it again on the S13 DRI ratifying negative_cache_ttl and position_wait_timeout — so this plan closes one of two and books the other.

The finding that shaped the step list

Both consumer specs say the machinery before Phase 2 is the winner path's, and "the difference begins after Phase 1". That is true of four of the five phases and false of the health gate, which is not visible from either spec.

loadAndFilter marks an unhealthy remote failed before Phase 1, so runPhase1 skips it and positionDetail.lookedUp stays false. serveOutage at resolve.go:615 then reads that flag as a bar on the cached copy, and says so: serving those "would undo the exclusion ... an unhealthy remote, skipped precisely so no request reaches it, would answer anyway."

Both specs require the opposite on the collection path: an unhealthy position "contributes its cached copy where one exists, fresh or stale, and is omitted where it has none." The two reconcile, because a cache read sends that upstream no traffic. What the code conflates is "send no request here" with "read nothing here", and Step 3 separates them.

The fourth round found a second piece that cannot be reused as it stands, and it is not one of the five phases: runPhase1's decision mapping. decisionForLookup gives a hosted Lookup miss, a hosted stale row and a row unusable for Lookup one failed decision between them, which a collection has to keep apart. Step 4 owns that mapping.

What the second round changed

Two statements in this section are superseded by the fourth round below: the "property of the seam" wording, and the resolve_timeout arm's assumption that Collect returns open bodies. The rest stands.

A review pass found two root causes behind most of its findings, and the plan now answers both.

Four properties the winner path guarantees by construction become contracts once Collect calls slice-supplied contributors. ## Approach derives them and Steps 4 and 5 carry each with an acceptance line and a test:

  • a recover() per position, so a panicking contributor cannot do what resolve.go:407-409 already warns about, which is take down every pod;
  • no sibling cancellation, since both specs make the merge best-effort while the two fan-outs in this package that an implementer would copy are fail-fast;
  • a cache-only contributor entry point carrying no fetcher and no prober, so "issues no request to it" (S30-maven-virtual.md:549, S31-npm-virtual.md:1396) is easy to honor and hard to breach by accident;
  • a stated owner for releasing every Contribution[T] the collection discards.

Step 4 also applies resolve_timeout and returns ResolveBudgetExpired distinctly from ErrResolverCancelled, which S13-virtual-remote-foundation.md:1038 requires as two arms and not one. That arm reads a field no merged MR declares yet, so Step 4's Depends on names whichever of S32's Step 11 and #1139 (closed) lands first.

The seam accounting was wrong. S30 books four internal/virtual seam changes (S30-maven-virtual.md:54) and this plan builds one. The other three are the batched Lookup wrapper, health arriving with the upstream list, and the per-position record on Resolve's own return value. An earlier draft said S32's plan books those three, naming its Steps 9, 10a and 11; that is wrong twice, because those are S32's own steps rather than S30's four, and because S32's plan reaches the first two slice-side in its Step 10b under its heading "Where the seam changes land: the spec says the foundation, and this plan says the slice". ## Context records the correction and ## Dependencies gives the residual an owner, alongside resolution.negative_cache_ttl, the second of the two keys S31 books and gates its collection step MR on (S31-npm-virtual.md:85).

Also corrected: Step 2's verdict arms contradicted themselves and dropped the all-upstreams-down case both specs answer 503; Step 1's enum is reconciled member by member against both specs' positions arrays and loses PositionVetoed, since S31 makes the hosted refusal a read-level outcome whose records survive; Step 5's fallback could not produce a typed payload from CacheFallback's LookupResult and now reaches the contributor's cache-only entry point; Step 3 gains the two files outside resolve.go carrying the same fact; NewResolver refuses two knobs, not four; and the generics rationale is re-derived, because internal/virtual exports nine package-level functions against four exported methods, only one of which (Resolve) is on Resolver, so a free function is the package's dominant shape rather than a departure from it.

Two steps edit resolve.go, and their merge orders differ

Step 3 is the health gate. Its order against S32's Step 10a is either, and whichever lands second rebases.

Step 6 adds the wait bound's field to Resolver. Whichever of S32's Step 11 and #1139 (closed)'s S13 MR declares ResolveTimeout must land first. The S32 plan's ruling at 2026-08-27-s32-container-virtual.md:3605 says "whichever of S31's Step 12 and this step lands second consumes the key rather than declaring it", and that pairing is stale on one side: S31's Step 12 merged (!2325 (merged), !2326 (merged), !2255 (merged)) declaring no field, which its own plan states at 2026-08-21-s31-npm-virtual.md:3119 and :3597. #1139 (closed) is the owner that survives, and internal/config/remotemapping_test.go:143 already records the field against it.

What it does not reach

No parser, no merger, no serializer, no document shape, no route, no handler. The winner path's behavior is unchanged and Step 3's Acceptance asserts it rather than assuming it. Three residuals are named with owners rather than built: resolution.negative_cache_ttl, the three other seam changes S30 books, and the per-read statement count, which is the consuming slice's batched-Lookup adapter to keep.

Reviewable LOC

2596 added lines, measured at 095fb6be0 against 07f4e8521: 2478 in docs/plans/2026-09-04-virtual-collection-mode.md, 117 in AGENTS.md, and 1 in docs/plans/README.md. The AGENTS.md group is the paired convention commits for this MR's remember: findings, so it is reviewed against the plan changes that produced it rather than on its own. Past the 500 ceiling docs/dev/development-model.md sets, and splitting would not help: a plan is reviewed as one document, and half of one states a step DAG whose other half is missing.

Every claim is measured at 07f4e8521 with one exception the file's header names: the claims about the S31 plan are measured at 7a76091b9, !2352 (merged)'s own merge commit and the first main carrying it, which is what added S31's cross-upstream merge to its plan. That commit is not reachable from this branch, so those claims need git show 7a76091b9:<path> rather than the working tree, which the header now says.

A second round corrected the first

A review pass over the ~500 lines the first correction added found six defects in that new text, and 172a223b9 fixes them: a generic Resolver[T] was described as carrying no payload, which inverts why it is rejected; the exported-surface count omitted the package's three other exported methods; Step 4's budget arm and its comment-only configuration edits each carried an unrecorded dependency, the second of them on the configuration-reference guardrail, so that sweep moved to Step 6; and two cross-references into ## Dependencies promised owners that were not there. The commit message lists them one by one.

A third round found three that would have reached a step MR

Four statements in this section are superseded by the fourth round below: PositionPanicked and PositionSlow on the data-error arm, the cancel running at the last contribution's Close, the all_unhealthy reconstruction, and the four-site "parallel HEAD probes" count. Each is named where it is reversed.

9574210bc answers them. Each was fixed at every site the fact lives at, not at the line the finding anchored to.

Step 2 never placed PositionPanicked in the verdict precedence. Step 4 mints it; the table withheld the 404 on PositionDataError alone. So one absent position beside one panicking contributor answered ErrFileNotFoundOnUpstreams — a 404 npm caches as a hard E404 and never retries, on evidence never gathered. S31-npm-virtual.md:1422 calls a contributor that fails to produce a document a data error of that position, so PositionPanicked and PositionSlow now take that arm, with the absent-plus-panicked pair pinned both ways round. The reconciliation table also gained the plan-to-spec direction it lacked: the value is in neither spec's positions array, and a slice emits failed for it.

Step 4 applied resolve_timeout to a call that returns N open bodies and named no owner for the cancel. A defer cancel() truncates every merged input at the return — which the S31 plan derives for the single-stream case at 2026-08-21-s31-npm-virtual.md:2914-2923, and which S13-virtual-remote-foundation.md:1039-1041 make three acceptance criteria of. The cancel now runs when the last contribution closes, the Acceptance drains every returned body past the call, and ## Dependencies books the release point against #1139 (closed), since the DRI's release-at-winner-selection ruling has no analogue on a path with no winner.

The ResolveTimeout dependency named S31's Step 12 at six sites, covered above.

Nine smaller corrections went in the same commit: the all_unhealthy justification claimed a slice recovers the distinction from []PositionRecord, which this document's own ## Naming Conventions disproves; Step 2's four-row table met a five-arm taxonomy in both specs, and the two missing arms are inherited from loadAndFilter's pass-through at resolve.go:308, which Step 3 now asserts survives gateMark; Step 3's comment sweep was exhaustive over the phrase "health gate" rather than the fact, missing resolve.go:33; Step 6 cited S31-npm-virtual.md:641 for a sanction at :643 and :646 for a sentence at :1201-1203, and "both specs sanction" is S31's argument alone; the "parallel HEAD probes" sweep named three sites of four; S30-maven-virtual.md:57 is about that spec's metadata merge and :59 is the spec-MR gate; S30-maven-virtual.md:460 does state the denial rule for the merge, so only the cached-bytes half is S31's alone; !2240 amends the S32 spec and touches no plan, so the container e2e rows are not booked yet; and "Three properties" in Testing Strategy had six bullets.

A fourth round resolved the payload decision and eight blocking defects

f22eee89b answers a review pass that found eight defects each of which would have reached a step MR, twenty narrower ones, eight observations, and one design decision this plan had left open. The decision is the headline, because four of the eight follow from it.

Collect no longer returns open upstream bodies. Each contributor finishes its read inside its own concurrency slot, so T is the slice's finished document rather than a reader the consuming slice drains after the call. The old shape broke four things a merged document asserts, and ### Every position's input is finished inside its slot, and that is what T is records the rejected option and derives each:

  • the probe_concurrency bound both specs make an acceptance criterion (S30-maven-virtual.md:383 and :559, S31-npm-virtual.md:641-642). A returned reader keeps its response open after its slot frees, and internal/remote/upstreamhttp/client.go:169-172 scopes an upstream request to include the body read, so twenty positions meant twenty responses in flight against a bound of ten. Holding the slot until Close deadlocks, because positions past the cap cannot start until the slice closes one and the slice cannot start until Collect returns.
  • each response's own request_total_timeout (default 30s), armed at that request's start and covering the body read, which expires while the slice drains its siblings. S30-maven-virtual.md:384 states it for the merge path and the plan had never named the key.
  • the budget's timer. S13-virtual-remote-foundation.md:1040 makes it an acceptance failure to leave "the budget's timer over the transfer", and handing the cancel to a body's Close stops that timer early without moving the deadline. The S31 plan says so one paragraph past the range the third round quoted, at 2026-08-21-s31-npm-virtual.md:2928-2934. The budget now releases at Collect's own last settle, which is the analogue of the DRI's release-at-winner-selection ruling for a path with no winner.
  • the verdict, since S31-npm-virtual.md:898-900 puts both the merged_partial and the 503 behind the parse, which a verdict settled before any byte was read could not see.

Step 2's success split was quantified over the wrong set. It read CollectMerged as "every position contributed", with the whole complement on the partial side including a denial. Both specs quantify over eligible positions and put an established absence on the merged side (S30-maven-virtual.md:701, S31-npm-virtual.md:1807-1809, with :1872 confirming from the other direction). A private package on [hosted, npmjs] would have recorded merged_partial on every read, pinning at its ceiling the rate both specs name as the signal the value exists to carry.

Step 2's not-found veto was inverted in both directions. It carried the contributor-side failures, which both specs make omissions answering 200 (S30-maven-virtual.md:554, S31-npm-virtual.md:1421-1423), and it never placed PositionIneligible, which is what S13-virtual-remote-foundation.md:1190 scopes the veto to and :664 gives the rule for: "The distinction is whether the position was consulted at all, not how confident the positions that answered were." S30-maven-virtual.md:533 makes the mixed list an acceptance criterion with the negative spelled out. This reverses the third round's placement of PositionPanicked and PositionSlow: S31-npm-virtual.md:1422 was cited for the wrong half of its own sentence, whose verdict clause is "the read answers 200". PositionIneligible is reachable because only an all-ineligible list short-circuits (internal/virtual/rules.go:330-331).

A second phase could not be reused as it stands, and no step owned the mapping. decisionForLookup (decision.go:111) collapses a hosted Lookup miss, a hosted stale row and a row unusable for Lookup onto one failed decision, so "the health gate is the one phase" was short by one. Reading survival off st.decisions would have dropped every hosted position with a stale or missing row from every merged document. Step 4 owns the state-to-record mapping now, and fixes which party mints which outcome per value.

The per-position recover() cited a precedent that re-panics. internal/remote/singleflight.go:200 re-panics at :212, deliberately and as its own doc at :174-180 states, which inside a fan-out child is the process death the contract exists to prevent. It cites internal/remote/healthjob.go:271-282 instead, carries the no-re-panic and an error log as contract, and records why that file's own pointer back to SingleFlight does not make the two the same shape.

The twenty warnings and eight observations, by area: Step 5's fallback arm kept only half of serveOutage's guard and so admitted a hosted position with a nil cache handle, repeated Phase 1's Lookup for the same path, and contradicted its own negative subtest about a data-error position holding a cache; its veto now waits for every position to settle before releasing, because a late contribution would hold a registered flight and a fill slot (internal/remote/singleflight.go:232-234). Step 4 applies the three zero-value guards Resolve applies at resolve.go:207-229. The health guarantee is stated as a contract the contributor honors, with the residual owned per slice, because a closure has the slice's own fetcher in scope. The recovered panic value reaches a log line, the records travel on both abort arms, PositionRecord carries the health mark Collect already holds, and the elapsed-time field says what it measures. Step 3's comment sweep was four sites short of the fact, including the block explaining the very arm the step gates, and decision.go joins its shared seams because S32's Step 10a adds a failed cause without touching that file. Step 6's "parallel HEAD probes" sweep named four sites where six carry the literal words and two more carry the fact; its reference-doc rows take a spec-free paraphrase, because guardrail 14 forbids spec references there; and its knob count was off by one under both orderings. The S30 seam acceptance is recorded rather than owed, since that spec merged as !2018 (merged) and the S32 plan says "The DRI accepts all three" at 2026-08-27-s32-container-virtual.md:235, and the three residual shapes have three owners rather than none. Four citations moved to the lines carrying their quoted text, and S30-maven-virtual.md:650 gained :639 for the half it never covered. Tests gained a case per outcome in the success split, both directions of the reversed veto arm, and arms for the zero value and an out-of-range one.

Merge order and file overlap

The plan file is new, so no MR can conflict with it. The index row this MR adds to docs/plans/README.md is the only overlap surface. Scanned at this branch's head f22eee89b against main at 63d2b8809, on 2026-09-07: all 94 open MRs of the project, every one of which had a refs/merge-requests/<iid>/head ref, so none went unchecked. Overlap is not conflict, so each candidate was run through git merge-tree rather than assumed:

MR Merges with this branch Merges with main
!2353 (merged) clean clean
!2361 (merged), !2395 (merged) clean conflict
!2005 (closed), !1011 (closed), !1009 (closed) conflict also conflict

The three that conflict with this branch conflict with main on the identical paths, so this branch does not create those conflicts and cannot resolve them; each needs a rebase whatever this MR does. This branch itself merges cleanly with main. No merge order is required in either direction.

Three notes on the set, since earlier revisions of this section were stale in three different ways. !2308 (merged) and !2055 (merged) were both in it and have both since merged, so neither is an open overlap; !2055 (merged)'s merge commit was main's head for part of 2026-09-07. !2361 (merged) and !2395 (merged) were created after the third round's scan and are new rows. And !1028 (closed) edits the same file but targets suleimiahmed/pypi, making it a stacked child of !1011 (closed): against its own parent its file set does not include docs/plans/README.md at all, so !1011 (closed) and !1009 (closed) are the shallowest members that both overlap and conflict, and those are the rows.

The date and both SHAs are named because this section is the one that goes stale fastest: the open set turned over by thirteen MRs and one row changed verdict between the third round's scan and this one.

This MR gates the step MRs it plans, under the plan-before-code guardrail, and nothing else.

Related to #266

A fifth round changed the payload shape

An 11-finding round plus one escalation. The escalation is the one that matters most, because it reversed this plan's single API decision.

Collect now hands each position's finished document back as that position settles, in position order, through a caller-supplied sink. It still returns only after the last position settles, carrying the records and the verdict, but no payload per position. The previous draft gathered every contribution and returned them together, defended by "a merge cannot merge a document it has not read, so every contributing position's document is materialized in the slice either way. Deferring changes when, not whether."

That justification is withdrawn. It holds only for a slice that buffers its whole output, and S31 designs for the opposite: S31-npm-virtual.md:749-756 streams versions position by position "as they are parsed", so "no object waits on a later position, so versions never accumulates", and :775-777 measures it at 54 to 60 MB of input in a 4 to 5 MB heap. The plan cited neither. A merge has to read every document; it does not have to hold every document at once.

So the hand-back aligns the foundation with S31 rather than overturning it, and no S31 amendment is owed for the payload shape. The gather shape would have owed one, and ## Dependencies now says both halves of that. The four properties the rejected reader shape cost all survive, which is what makes this a third option rather than a variant of it: the read still finishes inside its slot, so the probe_concurrency bound, each response's own request_total_timeout, and the parse-time outcomes are untouched.

Two caveats travel with the numbers rather than being left off. probe_concurrency is the floor and not the prize, since a bounded fan-out already holds that many documents whatever the hand-back shape. And position order re-admits a worst case: a slow position 0 holds back every finished position above it, so the resident set climbs back toward the upstream count until resolution.position_wait_timeout sheds it. That makes Step 6 load-bearing for the heap claim and not only for latency, and Step 6 now says so and carries its own assertion for it. S31's own caveat travels too (:778-780): the figures cover the merge alone and exclude the upstream fetches.

The other ten findings, each fixed at every site the fact lives at. Step 4's mapping could not tell a rule denial from a data error, because excludePosition drops the Reason it has in hand and records only the package-wide st.dataError, which a policy denial does not set at all; Step 3 now records the reason per position. The whole-collection resolve_timeout scope is kept but recorded as a deviation from S13-virtual-remote-foundation.md:1040-1041 rather than claimed as conformance with them, with the two consequences a step MR ships under it named and the ## Dependencies ask narrowed to one answerable question. Step 6 now says what its shed does to the position it sheds, as a cancelling per-position context child. Collect's settle wait became a whole-function property rather than the veto arm's, and its release sweep splits at the hand-back, since one rule for both either double-releases or leaks; the fill-slot escalation that used to justify the wait is withdrawn, because leaderBody.settle releases the slot on io.EOF and the plan's own ## Approach already said so. Step 2 gained the fail-closed scan's precedence, so one unfilled PositionRecord beside a real contribution can no longer fall out as CollectMerged; its totality property is now conditioned on a nil error, which the fail-closed arm otherwise violates by contract; and an all-PositionContributedFromCache set is pinned as CollectMerged, which is the outage the health mark exists to serve. Two fragments left behind by an earlier move of the mapping are gone. gen/** is in Step 6's Files, because a regenerated file is in the diff whatever "generated rather than written" says about hand-editing. The sweep's other-words half went from two sites to three. PositionPanicked has a reconciliation row, so the subtest quantified over that table keeps a single named exception. The panic log line names its four fields. Step 5 no longer consults a health-marked position a second time, and its unreachable CacheFallback branch is gone. decision.go's failed cause list gains runPhase1's producer at both sites that restate it. And the merge path's fallback observable is stated as the positions array rather than a per-position WARN, with ErrLeaderPanicked's indistinguishability booked as a named residual.

Nine of the eleven were remember: findings, so each convention is captured in AGENTS.md alongside its fix. They land as four sections rather than nine, because several findings shared one rule and AGENTS.md's own guidance is that a paragraph copied to two places leaves whichever copy is missed as a stale reference.

A sixth round settled the commit point

Four findings, all on the fifth round's own hand-back work, and one of them reopened a decision.

Collect now hands each position's document back as it settles, gated. The first sink call is where the status line and headers are committed (S31-npm-virtual.md:772-773), and the hosted veto cannot survive that: :614-616 requires a rebuild committing no row to discard the positions already collected rather than serving them. So Collect withholds the first sink call until every hosted position has settled, and applies that gate only when the upstream list holds a hosted position above index 0. The list is read before any fetch, so the condition is decidable at list-read time. Hosted at index 0, the npm virtual shape, never engages it; an all-remote list emits immediately; only the shape that can be harmed pays the delay. The gated arm's heap peak is stated as its own number, because a single figure covering both arms would be wrong on one.

The other three arms have no such gate and are booked as deviations rather than fixed: past the first sink call a budget expiry, a caller cancel, and a fail-closed record all degrade to a truncated body rather than restatusing. ## Dependencies carries them against S13-virtual-remote-foundation.md:1037, alongside the :1040-1041 question. :1039 joined that list too, since calling it vacuous was true of the gather and not of the sink.

A remote 404 had no route to PositionAbsent. S31-npm-virtual.md:634-635 establishes an absence for a remote 404 as well as a hosted Lookup miss, and the plan kept only the hosted half, with the minting contract built on it. Three wrong status codes shipped from that: an all-remote list answered 503 for every package no upstream holds, making the 404 unreachable on the commonest virtual shape; [hosted, npmjs] on a private scope reported merged_partial on every read; and a remote 404 beside a stale cache row served the stale bytes. The contributor now reports three things, produced, established-an-absence, or failed, PositionAbsent is minted jointly, and "No PositionOutcome value is minted by both" is withdrawn as the tidy invariant that caused it. Step 5's fallback keys on failed and excludes a reported absence by name.

Two counts and a wait. decisionForLookup collapses two hosted outcomes rather than three, because the unusable-for-Lookup row is a defect guard loadAndFilter already excludes rather than a Phase 1 state; the mapping arm and its subtest go with it. PositionDataError had lost its producer while the plan still claimed it "is already booked when it runs", so the contributor's failed report now carries the failure kind and a table gives the record for each report-plus-cache pair. And the settle wait moved into Step 4, where both abort arms ship: it had been stated in Step 5, so Step 4's own MR would have returned a record slice its children were still writing.

One arithmetic correction this MR made against itself. resolution.position_wait_timeout bounds the heap excursion's duration, not its magnitude. Slots free as fast positions finish, so at ADR-004's 20 upstreams and probe_concurrency 10 up to 19 documents still accumulate behind a slow position 0 whatever the key's value, and the peak stays at the upstream count. Both passages crediting the key with capping the magnitude are fixed, and so is the "has no bound on its worst case" wording that was wrong the other way.

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading