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_timeoutarm's assumption thatCollectreturns 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 whatresolve.go:407-409already 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:
PositionPanickedandPositionSlowon the data-error arm, the cancel running at the last contribution'sClose, theall_unhealthyreconstruction, 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_concurrencybound both specs make an acceptance criterion (S30-maven-virtual.md:383and:559,S31-npm-virtual.md:641-642). A returned reader keeps its response open after its slot frees, andinternal/remote/upstreamhttp/client.go:169-172scopes an upstream request to include the body read, so twenty positions meant twenty responses in flight against a bound of ten. Holding the slot untilClosedeadlocks, because positions past the cap cannot start until the slice closes one and the slice cannot start untilCollectreturns. - each response's own
request_total_timeout(default30s), armed at that request's start and covering the body read, which expires while the slice drains its siblings.S30-maven-virtual.md:384states it for the merge path and the plan had never named the key. - the budget's timer.
S13-virtual-remote-foundation.md:1040makes it an acceptance failure to leave "the budget's timer over the transfer", and handing the cancel to a body'sClosestops that timer early without moving the deadline. The S31 plan says so one paragraph past the range the third round quoted, at2026-08-21-s31-npm-virtual.md:2928-2934. The budget now releases atCollect'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-900puts both themerged_partialand the503behind 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.