docs(plans): append the remote parent tombstone scan steps to the S20-A plan
What this changes
This merge request amends docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md and two clauses of docs/specs/S20-a-lifecycle-closed-beta.md.
It appends two steps, 20a and 20b.
Those two steps are the work that work item #728 asks for.
This merge request adds their plan, not the fix.
It adds no code.
The spec edit travels with the plan because this merge is what makes it wrong. Two clauses in the spec state that no numbered step of the plan owns the three remote scan levels. This merge request adds the two steps that own them. Both clauses are correct until it merges and false after. Step 20b is one merge too late for that correction.
The plan amendment is an append plus two sentence rewrites. It adds two Status rows, two size-forecast rows, two step sections, one Dependencies bullet, and eight Research Findings subsections. It renumbers no step and rewrites no existing table row.
Both rewritten sentences replaced a reference to the size-forecast table's "last two rows".
That phrase named rows 19a and 19b until this amendment appended rows 20a and 20b beneath them.
The two sentences sit in different sections, and the section on !1959 (merged) below gives each one.
The spec edit rewrites its two clauses in place and appends nothing.
Against main the diff is 390 insertions and 4 deletions over the two files: 388 and 2 in the plan, 2 and 2 in the spec.
The Status table goes from 22 rows to 24, so the step markers are 20a/24 and 20b/24.
Guardrail 3 requires a plan merge request to merge before any implementation merge request opens. This is that merge request.
The gap the two steps close
Three remote parent tables carry a soft_deleted_at writer on main and no purger scan level.
The tables are npm_remote_packages, maven_remote_packages and container_remote_images.
Each table already carries its discovery index, from migration 20260819135121.
A marked row under a live repository is reached by nothing.
Step 20a grows a versions leg on NpmRemotePackageReaper and MavenRemotePackageReaper.
Step 20b adds the three scans, registers the three purge levels, and converts two reap arms to report the bytes they free.
Corrections this amendment records
The project rule asks a plan to record a contradiction between its research and the issue or spec it implements. Five such corrections are in the plan text, under Research Findings.
The issue's reachability section is stale.
It states that no request path reaches the three evictors.
!1726 (merged) and !1761 (merged) merged on 2026-08-25.
internal/managementapi/maven_remote_eviction.go, internal/managementapi/npm_remote_eviction.go and internal/managementapi/container_remote_eviction.go are all on main.
Every one of the three writers is reachable through a mounted route.
The issue's scope reading is inverted.
It calls a net-new downward walk from the parent tombstone the larger half of the work.
ContainerRemoteImageReaper already drains its whole subtree, so for container_remote_images the scan level is the entire remaining job.
For the two package tables the parent reapers exist and carry a caller contract that every version beneath is already reaped.
The remaining piece there is a versions leg on an existing arm.
The specs assert an upward leg that merged code does not have.
docs/specs/S20-a-lifecycle-closed-beta.md:269-270 and three sites in docs/specs/S17-rest-management-api.md say a version reap reaches its parent package row.
No merged reaper does that.
The Reaper contract states the opposite at internal/datastore/lifecycle_scan.go:302-303: "Every reap keys on (namespace_id, id) and no reap joins upward".
Two routes remove a parent row today, and both run downward: a scan level on the parent table, and RepositoryReaper's walk.
This went to the spec authors as an escalation on 2026-08-27.
Neither step amends the upward-leg claim in these specs, and neither waits on that amendment.
The chunk driver's reaper count is wrong.
internal/lifecycle/chunk.go:88-95 says two of eight reapers never set Drained.
ContainerRemoteManifestReaper sets it unconditionally at internal/datastore/lifecycle_reap_container_remote.go:226, so the true count at head is one of eight.
git blame shows the sentence was wrong on the day it was written.
Step 20b re-derives it against eleven levels.
One earlier statement about the npm recompute over-reached.
recomputeNpmRemoteFilesSizeStmt unions a tarball arm and a packument arm.
Only the packument arm reads rows that NpmRemotePackageReaper deletes.
The plan does not repeat the over-reach.
This changes no decision, because the npm remote arm keeps its zero.
Size figures this amendment supersedes
The forecast moved twice, and the plan names both moves.
| Figure | Superseded by | Why |
|---|---|---|
| 2400-3400 LOC for the whole work | 3810-4110 with the container zero left in place | Re-derived at head after !1912 (merged) merged |
| 900-1900 LOC for the first merge request | 1980-2160 | Same re-derivation |
| 285-385 for the container freed-bytes threading | 185-270 | Only two of the four child legs carry digests, and the survivor family is already merged. It is Step 20b's 2015-2220 less the 1560 scan-and-level half and the 270-390 the arm's zero already carries |
The versions leg re-measured and the freed-bytes obligation both drive the first move. The freed-bytes work accounts for 270-390 of the growth, against the leg's own 1080 at the bottom of both ranges and 260 at the top, so which of the two is the larger share depends on where in the range the work lands. The growth falls on every split option equally, so the two-merge-request split is not reopened.
Converting the container arm puts the whole work at 3995-4380. Both step merge requests run past the plan's 1600 degradation point. Each one carries the size justification with its LOC split by file group.
Merge orders the plan records
- Step 20a merges before Step 20b's merge request opens. While 20a is unmerged, a new level dispatches a package row whose versions are live, and every chunk aborts with
ErrReapParentPinned. Once 20a merges, both arms drain their versions in the same call. - Step 20b merges before sibling issue #937 (closed)'s work, which rebases onto it. 937 has no merge request. Its branch registers its own purge level and renumbers the same counts, in the same regions. Nothing in the tree computes those counts, so a rebase that takes either side whole compiles, passes, and publishes a wrong number on
/-/metrics. - Work item #834 (closed) supplies the increment for the converted container arm. Two conditions open the window. A caller has to turn the reap's figure into a counter delta, which nothing does while
internal/lifecycle/chunk.go:102-103forcesSkipFreedBytes. And #834 (closed) has to be unlanded. While both hold, a container remote repository'ssize_bytescan read low between a cached-image purge and the next reconciliation pass. Once #834 (closed) lands, the fill credits the column. - ADR-007 arrives in two hops. handbook !20887 merges, and then the daily ADR sync lands the mirror update. Until both hops complete, merged ADR-007 governs this work. No handbook merge request is opened here.
Collision with !1959 (merged), and how it was reconciled
!1959 (merged) landed first, as squash 7379791f8.
This amendment rebased over it, and the rebase conflicted in this one file, in two places.
The size-forecast table keeps !1959 (merged)'s re-measured rows for Steps 19a and 19b, together with this amendment's appended rows for Steps 20a and 20b. Neither side changed a row the other side changed, so both sides are kept.
The measurement paragraph under the table is !1959 (merged)'s text.
Its first sentence opened "The last two rows are measurements rather than forecasts", and the two appended forecast rows made that false, because the last two rows are now 20a and 20b.
The sentence therefore names its rows instead of their position: "Rows 19a and 19b are measurements rather than forecasts".
Every figure in that paragraph, the merge-commit citation 79f8705cb and every other claim in it are !1959 (merged)'s, unchanged.
The commit body of 5ae905189 records which side each part came from.
A second sentence carried the same positional reference, in the Research Findings section on the Step 19 split.
It opened "Each figure in this paragraph and in the table's last two rows names the coordinate it was taken at".
That sentence predates !1959 (merged), and this amendment falsifies it for the same reason, so it now names the 19a and 19b rows.
The rest of that sentence is unchanged.
The two new Status rows took no conflict, because !1959 (merged)'s last hunk in this file ended before the ## Status heading.
Step 20b's own Merge order note described !1959 (merged) as an open merge request that would conflict.
That note now records the merge and the resolved conflict, and its main line-number citations are dropped rather than re-derived.
One measured fact that changes the plan's shape
scripts/ci/check-comment-caps.sh caps every Go comment block a diff touches.
The caps are 3 lines for a package comment, 3 for an exported doc, 1 for an unexported doc, and 2 for anything else.
Each site in the plan's table was measured by an edit inside the block and a run of the script, not counted by eye.
Two results decide where prose lands.
The internal/lifecycle/chunk.go count sits inside a 28-line block whose cap is one line, so the correction moves that block's argument to a package-local sidecar.
ContainerRemoteImageReaper.Reap's doc is a 104-line block against a 3-line cap, so its prose moves to docs/dev/storage-accounting.md.
Checks
markdownlint-cli2,valeandlycheepass on both changed files.- The query-name budget counts, the Status row count, and the merge state of !1959 (merged) were measured at
origin/maine715aafe1. - The other line numbers cited in the new text were read at
origin/mainc57b6c7af, the base this branch is rebased onto. Every anchor corrected during review was re-derived by reading the file at that commit rather than by subtracting an offset.mainhas moved since, so these citations record that base rather than a re-check at head; thedocs/dev/storage-accounting.mdanddocs/specs/S17-rest-management-api.mdcitations move again on the next rebase, and theinternal/datastore/lifecycle_scan.gocontract moved intodocs/dev/storage-accounting.mdwhen !1916 (merged) merged.
Related to #728 This merge request lands the plan for Steps 20a and 20b, and their own merge requests fix the defect that the issue reports.
This is a bot message