feat(virtual): per-position collection outcome vocabulary (S13 collection mode plan: 1/6)
What
Step 1 of the shared internal/virtual collection mode plan: the PositionOutcome enum, its String, and the PositionRecord that carries one position's verdict. No I/O, no resolver, and nothing consumes either type yet.
PositionOutcome has ten values. PositionUnknown is the zero value and stringifies unknown, so a record nobody filled reads as unfilled rather than as a contribution. An out-of-range value stringifies invalid_position_outcome, matching ResolveOutcome.String in the same package.
PositionDataError's doc comment names where S30 rules the opposite way, because that value is the contract Step 5 is written against; the Follow-ups list carries the amendment that closes it. PositionRecord carries the position index, the outcome, the eligibility Reason, the health gate's mark, and the position's own elapsed span. It has no constructor, so its zero value is declarable, and that zero value's behavior is pinned by a test rather than left to a reader.
Reconciliation against the consuming specs
The tests reconcile the enum against each consuming spec's own positions vocabulary, per spec rather than over the union of the two. S30 names 13 tokens (S30-maven-virtual.md:709) and S31 names 13 (S31-npm-virtual.md:1859-1860), sharing 10, and the two disagree on three of those shared ten: failed, hit_fresh and hit_stale. A union hides every disagreement behind whichever spec happens to name a home, which is why every assertion runs against one vocabulary at a time.
unhealthy is a winner-path token under both specs, so a collection mints no value for it. S30:709 scopes only hit_fresh and hit_stale away from the merge path, by replacing them with contributed/contributed_stale. It does not scope the other eight: :710 gives miss and failed merge-path meanings of their own ("on the merge path, miss records an established absence and failed an omission with no cached copy"), which is why the fixture homes both for Maven. unhealthy is the one token of the ten that S30 never gives a merge-path meaning, and :379, :474, :549 and :801 each route a health-marked merge position to its cached copy or to failed instead. An earlier round of this description said :709 scoped all ten, which contradicted :710 and the same sentence's own use of failed as a merge-path token. S31:1872-1873 folds the no-cache case into omitted, and :1879-1880 sends a marked position that did contribute to its freshness token ("keeps hit_stale or hit_fresh, because from the merge's point of view it answered"). Unlike S30:709, S31 nowhere states in one line that unhealthy is winner-path only, so for that spec this is a derivation from those two passages rather than a quotation, and it is labelled as one here because the absent row rests on it. Both specs therefore carry an absent row for it with its own reason. Neither spec's positions entry has a health field at all (S30:708, S31:1858-1860 both list exactly position, kind, upstream_repository_id, outcome, duration_s), so PositionRecord.Unhealthy says on its own doc comment what a collection loses by having no token for the mark. Read at 3234d7f33, that comment names the absence and its consequence and no more: the mark feeds the read's own resolution outcome rather than a per-position token. The operating reason for carrying the field lives on PositionOmitted instead, which says the mark is what separates a position never consulted under it from one consulted and unable to answer. c61085256 moved that half, and an earlier round of this description went on crediting the field's own comment with it. The clause also read "not the verdict" until 7e49dff02, which collided with three live referents in the package: virtual.verdict in decision.go, whose winner result is how the resolution outcome is reached, PositionRecord's own doc calling the record one position's verdict, and Reason's doc calling itself the eligibility verdict. The mark reaching no positions entry is a residual, recorded on the issue.
S32 carries the same shape for tags/list and referrers, and its collection amendment is still open, so it has no collection vocabulary to reconcile against yet. specReconciliations names that condition rather than describing itself as covering every spec.
Spec coverage
Spec: docs/specs/S13-virtual-remote-foundation.md, which owns internal/virtual and the format-blind collection mode. The behavior is specified in the two merged consumer specs, S30 Package-level metadata merge and S31 Cross-upstream packument merge.
Every subtest name below was re-derived from the branch head with go test -v -run TestPosition, not carried from an earlier round. Reconciliation subtests are named spec/row, so each is cited with its spec segment.
Acceptance criteria
S13's own ## Acceptance Criteria section states no collection-mode criterion. Its only two mentions of the mode scope themselves out of it: "One upstream reports 404 while another is unreachable" and "Unhealthy upstream skipped in winner-path resolution" both say the read under test is a winner-path one, and the second adds that a collection's criteria "live with the slices that drive it". So the rows below come from S13's #### Collection mode design contract and from the two consumer specs.
Row ids are this table's own: S13's #### Collection mode numbers nothing. CM-1 to CM-3 are the three things its contract sentence names, CM-4 to CM-9 are its six bullets in order, and CM-10 is a clause of the health-gate bullet.
| # | Criterion (S13 #### Collection mode) |
Tests |
|---|---|---|
| CM-1 | Ask every eligible position for its document | Not in this MR: no I/O and no resolver here. Owned by Step 4 (Collect). |
| CM-2 | Record each position's own outcome: a contribution and whether it came from the upstream or from cache, an absence, an omission, or a data error | TestPositionOutcome_String, TestPositionOutcome_EverySpecTokenHasOneHome, TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken, TestPositionOutcome_EveryDefinedValueIsReconciled, TestPositionRecord_ZeroValueReadsAsUnfilled, TestPositionRecord_UnhealthySeparatesTwoOmissions |
| CM-3 | Settle one aggregate verdict over those records | Not in this MR. Owned by Step 2 (CollectVerdict). |
| CM-4 | No Probe runs on a collection path | TestPositionOutcome_SpecTokensWithNoValueHere/maven/no_probe_runs_on_a_collection_path and .../npm/no_probe_runs_on_a_collection_path cover the vocabulary consequence (probe_hit and probe_fresh get no value). The fan-out itself is Step 4. |
| CM-5 | The health gate marks the position instead of excluding it, and a failed fetch is marked the same way | TestPositionRecord_UnhealthySeparatesTwoOmissions (the two omissions are built independently and asserted to agree on every field a slice emits, then to differ only once the mark is cleared). The gate behavior is Step 3, merged as !2422 (merged); the fallback is Step 5. |
| CM-6 | Each position's outcome is recorded rather than collapsed | TestPositionOutcome_EverySpecTokenHasOneHome, TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken, TestPositionOutcome_EveryDefinedValueIsReconciled, TestPositionOutcome_SpecTokensWithNoValueHere |
| CM-7 | Fan-out is bounded by resolution.probe_concurrency |
Not in this MR. Owned by Step 4. |
| CM-8 | A collection is best-effort, and a partial one is a success | Not in this MR: the split is over records, not values. Owned by Step 2. |
| CM-9 | A slice may arm a per-position veto | Not in this MR. Owned by Step 5. No PositionVetoed value exists by design: S31 makes the refusal a read-level outcome and the vetoing position keeps its own outcome. |
| CM-10 | A denied position contributes nothing, its cached copy included | PositionDenied is the value that records it, asserted in TestPositionOutcome_String and reconciled from the denied token. TestPositionOutcome_EveryReasonSelectsItsOutcome pins that it is the one Reason the eligibility pass calls a policy denial and that every data-error Reason selects PositionIneligible instead, which is what both values' doc comments quantify over and nothing checked before. The "contributes nothing" behavior is Step 4. |
| # | Criterion (S30 and S31) | Tests |
|---|---|---|
| S30-1 | Every one of the 13 outcome tokens S30's positions array names has a value here or a stated reason it has none |
TestPositionOutcome_EverySpecTokenHasOneHome/maven/* (13 subtests, one per token), TestPositionOutcome_SpecTokensWithNoValueHere/maven/* (4 rows), and TestPositionOutcome_ReconciliationBasesAreComplete/maven, which pins this file's transcription at 13, so a token deleted from it fails instead of quietly narrowing every quantifier. It does not read the spec: a token S30 gains and nobody transcribes here still drops out silently, which the fixture's own comment states and which stays a review-time check |
| S30-2 | no_stored_document distinguishes an under-reporting hosted upload from a package the hosted repository does not hold |
Slice-side by design: a Maven refinement of PositionAbsent. Asserted as slice-side by TestPositionOutcome_EverySpecTokenHasOneHome/maven/no_stored_document, and checked against S30's own array by TestPositionOutcome_SliceSideTokensAreNamedBySpec/maven/no_stored_document. |
| S30-3 | contributed and contributed_stale are distinct on the merge path |
TestPositionOutcome_String, TestPositionOutcome_EverySpecTokenHasOneHome/maven/contributed_stale, TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken/maven/contributed and .../maven/contributed_from_cache |
| S30-4 | Best-effort under outage: a failed position contributes its cached document or is omitted | Not in this MR. Owned by Steps 4 and 5. The two values the arm books, PositionContributedFromCache and PositionOmitted, are pinned by TestPositionOutcome_String. |
| S31-1 | Every one of the 13 outcome tokens S31's positions array names has a value here or a stated reason it has none |
TestPositionOutcome_EverySpecTokenHasOneHome/npm/* (13 subtests, one per token), TestPositionOutcome_SpecTokensWithNoValueHere/npm/* (5 rows), and TestPositionOutcome_ReconciliationBasesAreComplete/npm, which pins this file's transcription at 13 on the same terms. An earlier round of this description claimed #876 taking S31's array to 14 would land as a red test here; it will not, because the assertion measures the literal in this file and not the spec. Transcribing probe_absent when #876 lands is a review-time obligation, not one a test enforces |
| S31-2 | omitted and timed_out are distinct, because failed alone cannot say whether a failure cost the response anything |
TestPositionOutcome_String (omitted and slow), TestPositionOutcome_EverySpecTokenHasOneHome/npm/omitted, .../npm/timed_out, TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken/npm/omitted and .../npm/slow |
| S31-3 | negative_cached is a replayed absence and not an omission |
No value here by design: the negative cache is outside this plan. TestPositionOutcome_SpecTokensWithNoValueHere/npm/no_negative_cache_is_built_here_yet |
| S31-4 | hit_fresh and hit_stale carry a cache row's freshness |
Contribution homes under S31, which has no contributed token: hit_fresh takes PositionContributed and PositionContributedFromCache, hit_stale the second alone. TestPositionOutcome_EverySpecTokenHasOneHome/npm/hit_fresh, .../npm/hit_stale, and TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken/npm/contributed for which token each value takes. |
| S31-5 | unhealthy names a health-marked position |
A winner-path token under both specs, so a collection mints none: TestPositionOutcome_SpecTokensWithNoValueHere/npm/unhealthy_is_the_winner_path's_own_health_token and the Maven twin. The mark itself rides PositionRecord.Unhealthy, asserted by TestPositionRecord_UnhealthySeparatesTwoOmissions and TestPositionRecord_ZeroValueReadsAsUnfilled. |
| S31-6 | skipped_soft_deleted records a position the soft-delete skip removed |
No value here by design: the skip drops the position before any record exists. TestPositionOutcome_SpecTokensWithNoValueHere/npm/the_soft-delete_skip_runs_before_a_record_exists and the Maven twin. |
| S31-7 | A contributor serving a document that is over-cap or fails to parse is omitted, and logged as a data error of that position | S31-npm-virtual.md:1421-1423 is the omission rule and its subject is a contributor that served an unusable document. PositionDataError records having answered at all and takes S31's omitted token: TestPositionOutcome_EveryDefinedValueCarriesItsSpecToken/npm/data_error. PositionPanicked is a separate case this plan owns, in neither spec's array, homed on omitted under S31 and failed under S30: .../npm/panicked and .../maven/panicked. |
Error cases
| # | Condition (S13 ## Error Cases) |
Tests |
|---|---|---|
| E-1 | No winner, some position established an absence, no data-error exclusion: FileNotFoundOnUpstreams |
Not in this MR. Owned by Step 2. PositionAbsent is the value the arm reads, pinned by TestPositionOutcome_String. |
| E-2 | No winner, some position established an absence, another excluded for a data error: never FileNotFoundOnUpstreams |
Not in this MR. Owned by Step 2. PositionIneligible is the value that withholds it, pinned by TestPositionOutcome_String. |
| E-3 | No winner, no absence established, no cache entry: UpstreamUnavailable |
Not in this MR. Owned by Step 2. |
| E-4 | Nothing eligible with at least one data-error position: AllUpstreamsIneligible |
Not in this MR. Owned by Step 2, over PositionIneligible records. |
| E-5 | A record nobody filled must not read as an answer | TestPositionOutcome_UnknownIsTheZeroValue, TestPositionRecord_ZeroValueReadsAsUnfilled. Step 2 adds the fail-closed verdict arm. |
| E-6 | An out-of-range outcome value | TestPositionOutcome_StringPastTheDefinedValues (past the last defined value by name, past the defined count, and below zero) |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Error-payload hygiene: a reason a client can see is a fixed enum token and never carries network detail | TestPositionOutcome_String and TestPositionOutcome_StringPastTheDefinedValues: String answers from a closed token set, and an undefined value reports invalid_position_outcome rather than echoing the integer. |
| S-2 | Logs carry only metadata, never credentials, upstream hostnames, URLs, or resolved addresses | PositionRecord declares no such field: an index, an outcome, a Reason, a bool, and a duration, all five read by TestPositionRecord_ZeroValueReadsAsUnfilled. A field added later is now caught: TestPositionRecord_UnhealthySeparatesTwoOmissions asserts the field count, measured by adding a sixth field and watching it redden. |
| S-3 | A denied position's bytes are not served, so a denial is not silently upgraded to a contribution | PositionDenied and PositionContributed are distinct values with distinct tokens (TestPositionOutcome_String), and PositionUnknown never reads as a contribution (TestPositionOutcome_UnknownIsTheZeroValue). The enforcement is Step 4's. |
| S-4 | SSRF, credential stripping, TLS, outbound proxy, audit events, deny-rule scoping | Not reached by this MR: a pure enum and a record struct with no I/O. Owned by the existing internal/remote covers and by S13's already-merged resolver work. |
Diff size
660 reviewable LOC at 3234d7f33, over the 500 the development model asks a change to justify. Re-derived from git diff origin/main...HEAD --numstat at that commit rather than carried from the previous round's 609. Split by file group: internal/virtual/position.go 97 (new, production; 48 lines of declarations and the String switch, the rest doc comment and blanks), internal/virtual/position_test.go 558 (new, test), internal/virtual/result.go 5 (a two-line comment correction). Splitting would not help: the production half is a 97-line enum and record, and the rest is the suite that pins it. Separating the two would land an enum whose reconciliation against the consuming specs is unasserted, which is the whole point of the step.
result.go is in the diff, and why this step edits it
ResolveResult.Position concluded that its cardinality was "safe there (bounded by the upstream-list length)". That bound does not exist: the only position constraint in the schema is non-negativity, the npm migration records that there is no trigger, maven_virtual_repository_upstreams has no migration at all, and no Go path enforces ADR-004's 20-source cap. PositionRecord.Position states the corrected version and points at ResolveResult.Position with a doc link, so leaving the sibling would have the two disagreeing while cross-referencing each other. No open MR touches it. Re-derived on 2026-09-10 against every open MR whose head ref resolves: the only file-overlap hit is this MR itself, and git merge-tree returns clean. The candidate count is deliberately not quoted here, because the open set moves without the branch moving, so a figure pinned to a commit cannot be re-derived from that commit; a later run of the same scan returned a different total and the same conclusion. The plan's Step 1 entry did not list it and its Shared seams field said this step opens no other file in the package; !2483 (merged) now carries both, so the entry reads as a record of what shipped.
Testing
go build, go vet, gofmt, and go test -count=1 ./internal/virtual/... all pass. golangci-lint run ./internal/virtual/... reports 0 issues both untagged and with --build-tags=integration. The comment-caps gate passes against origin/main.
Every assertion in the new suite was checked by breaking it rather than by reading it. Re-homing a token, deleting a token from a spec array, deleting an absent row, double-homing a token, inventing a slice-side token, renaming a String return, and inserting an enum value mid-block each redden the suite; the inserted-value case reddens only the len(definedOutcomes) subtest, which is what that subtest's comment claims and the only case that catches that edit.
A second review round found two more that stayed green, and both now redden. Adding an invented key to npmReconciliation.outcomes and mirroring it into wantTokens passed every assertion in the file: assertExactlyOneHome walks the spec's own array, so a key that array does not name is never visited, and the two maps are authored together so the value-first test compared the invention against itself. TestPositionOutcome_MappedTokensAreNamedBySpec walks those keys the way the slice-side test walks its list, and the mutation was re-run to confirm every pre-existing test stays green while only the new one fails. outcomes carries 6 keys for Maven and 7 for npm against sliceSide's 1, so the unguarded map was the larger of the two. The slice-side entry also carries the value it refines now, asserted both ways and measured by pointing it at an undefined value and then at PositionSlow.
TestPositionRecord_UnhealthySeparatesTwoOmissions built its second record by copying the first, so seven of its eight assertions could not fail against any compiling struct: four compared a field against an unmodified copy of itself, two restated a literal two lines above, and one asserted only that Go struct equality includes a bool. Both records are written out now, and the pairing is asserted by clearing the mark on one and requiring the two to be equal, which reddens on a second field the two routes disagree on. Measured by giving the consulted record a different Position.
An earlier round found two mutations that stayed green, and both redden. Deleting "miss" from mavenReconciliation.tokens passed the whole suite, because EverySpecTokenHasOneHome walks that array and a token missing from it is never asserted rather than arriving unhomed; dropping npmReconciliation from specReconciliations passed the same way, with every npm assertion simply not running. TestPositionOutcome_ReconciliationBasesAreComplete pins both sizes and each mutation was re-run against it to confirm it fails. The retargeted TestPositionRecord_UnhealthySeparatesTwoOmissions was checked the same way: setting the two records' Unhealthy equal reddens it, where the case it replaced asserted the mark on the pairing no slice's token turns on.
A third review round found two more that stayed green, and both now redden. The slice-side test's comment claimed that a slice booking the refinement for something the value does not cover reddens against its two assertions; nothing in the file sees what a slice books, and re-pointing no_stored_document's refines from PositionAbsent to PositionContributed passed both, because "contributed" is neither the invalid token nor unminted under Maven. refinesWant now pins which token the refined value carries, read value-first the way wantTokens is. And TestPositionRecord_UnhealthySeparatesTwoOmissions claimed a second discriminator added later fails it; exhaustruct is disabled, so a sixth PositionRecord field compiles unset in both literals and both pairing assertions still passed, measured first in a scratch program and then against the suite. The field count is asserted beside them.
TestPositionOutcome_EveryReasonSelectsItsOutcome is new, because PositionIneligible's doc quantifies over every Reason the eligibility pass calls a data error and PositionDenied's over the one it calls a policy denial, and nothing checked either claim. Reason has no String, so its row count pins the transcription only, which the comment says rather than implies. All five guards added this round were checked by breaking them one at a time: five mutations, five reds, each re-run against the whole package.
No e2e scenario catalog changes. This step adds no route, no request path and no observable behavior: nothing constructs a PositionRecord and nothing emits a PositionOutcome token yet, so there is no client-visible behavior for a scenario to exercise. The first catalog change in this plan belongs to the step that wires a slice to Collect.
Plan bookkeeping
This MR does not touch the plan file: guardrail 4 gives it a single writer, and a step MR editing its own Status row is what that rule forbids. Step 1's row was not recorded when this MR opened, which guardrail 4 asks for in the same sitting. It is recorded now in !2483 (merged), together with three corrections to Step 1's entry: the unhealthy row, which homed the token on PositionRecord's health-mark field where what shipped homes the token nowhere and keeps the mark as a field beside it; the Files list and Shared seams field, which did not carry this MR's result.go edit and denied that any other file in the package was opened; and the data-error paragraph, which called S30 "silent" on a position holding a cached copy when S30-maven-virtual.md:701 is not silent (see the round notes below).
!2483 (merged) should merge first, or in the same wave. The two do not conflict textually, and an earlier round of this description called them order-free on that basis. That is the wrong test: if this MR merges first, Step 1's merged Acceptance describes an unhealthy mapping that did not ship and a Files list missing a file that did, and a Step 2 or Step 4 author reads that entry to build a file set. The window is the only cost, so this is a stated order rather than a hard gate.
Follow-ups recorded, not fixed here
Eight, and the issue carries seven of them rather than each becoming a new issue. Item 5 says in its own text where it is handled instead. Counted against the list below at 3234d7f33.
- S30 names no per-position wait bound and no
timed_out, soPositionSlowis unminted under that spec. The reconciliation states this as a condition, becauseresolution.position_wait_timeoutsits on the sharedvirtual_repositories.resolutionblock: once Step 6 lands, a Maven collection can shed a position no S30 token describes. Owner: Step 6, via an S30 amendment. S30-maven-virtual.md:747rests a memory-bound security argument on apositionCHECKonmaven_virtual_repository_upstreamsthat does not exist, and Maven associations are written by direct SQL today. Owner: an S30 amendment.- Neither spec's
positionsentry carries a health field, so the markPositionRecord.Unhealthyholds never reaches a wide event: while that stays true a slice can deriveall_unhealthyfrom the records but an operator cannot read the mark per position. Owner: an S30 and S31 amendment adding the field or a merge-path token, whichever the slice authors want. - ADR-004 caps upstream sources per virtual repository at 20 and says the cap is enforced at write time with a
422; nothing enforces it. This MR stopsResolveResult.Positionclaiming the bound exists but does not add it. Owner: the association write path, when S17's endpoints are built for a format. - After this MR both
ResolveResult.PositionandPositionRecord.Positioncarry a cardinality note andremote.UpstreamExclusionEvent.Position(internal/remote/audit.go:237) carries none, while its own neighbourPathtwo lines below does. Three sibling fields with two of them annotated reads as a deliberate difference. Deliberately left: it is outside Step 1's Files list and outside this MR's package, and adding an editor to a file this step has no other reason to open costs more than the note is worth. Owner: whoever next opensinternal/remote/audit.go. - S30 rules a contributor-side data error the opposite way from
PositionDataError's definition, and not only by omitting a qualifier at:701.:379closes the cache-fallback trigger list to a health-marked remote or a transport or5xxfailure, and:554says an over-cap, unparseable orDOCTYPE-carrying document "is omitted, logged as a data error of that position" with no cache clause, so under S30 a Maven data error is omitted whatever it holds and the merge ismerged_partial.7e49dff02names that divergence atPositionDataErroritself, where Step 5 will read it, rather than only at the Maven fixture's gloss. Guardrail 4 keeps the plan edit out of a step MR, so Step 5's Depends on is where the amendment belongs. Owner: an S30 amendment, gating Step 5. - Both specs ask for a record on every position excluded before a request goes out (
S30-maven-virtual.md:714), and a soft-deleted position is one; this seam mints no value for it. The plan books the per-position record onResolve's own return value among the three seam changes it leaves unbuilt, and## Contextrecords that one as unowned. The fixture's Maven row now points at the issue so the gap reads as tracked. Owner: unowned in the foundation, tracked on the issue until a step or spec claims it. S13-virtual-remote-foundation.md:971sizes a collection wave asceil(20/10) * 30s=60sof90s"at the ADR-004 cap", so it rests on the same unenforced cap item 4 names. npm's read admits up tonpmVirtualUpstreamListLimit=2 * maxNpmVirtualUpstreams= 40, and at theprobe_concurrency: 10default a 40-position wave is120s, past the whole budget. Nothing mis-serves today: reaching 21 positions needs direct SQL, and:971already routes an over-budget wave toResolveBudgetExpiredand a503. Steps 4 and 6 read that paragraph. Owner: the association write path, with item 4.
Round notes
At least four commit bodies on this branch are stale, and this description is the surface that is maintained. Counted at 3234d7f33 by reading every body against head, not incremented from the two an earlier round of this section claimed.
1063fd5ca's spec-coverage table names TestPositionRecord_CarriesAnUnhealthyCacheContribution three times. 806124f00 renamed and retargeted that case to TestPositionRecord_UnhealthySeparatesTwoOmissions, and a later commit rebuilt it again, so the name resolves to nothing at head. The same body asserts "Every subtest name below is re-derived from the branch head rather than carried from this commit's own tree", which was true when written and stopped being true one commit later. 1063fd5ca also describes the mark as riding the record beside PositionContributedFromCache, which is the pairing that commit's successor deliberately dropped.
Three more, found by reading each body rather than by trusting the count. 3e7e6558f says of the Unhealthy block "All three are now named, with the consequence"; head's comment names none of the three readings. 61357a3f1 says "Both keep every claim at 120 and 116 columns", which was true at that commit and is 143 and 142 at head, measured with tabs as 4. b9f07fa13 calls the field "the only thing separating a cached contribution taken under a health mark from one taken after a fetch failure", which 562d55aaa's own body retracts.
Not fixed by rewriting history. This MR has squash_on_merge set, so no commit body reaches main; correcting one costs a replay of twelve signed commits and a leased force-push, against a body that is discarded at merge. The tables above are re-derived at 3234d7f33 and are the ones to read. Flagged here so a reviewer reading the commit list is not sent hunting for a test that does not exist.
Related to #1244 (closed)