Package- and image-level remote tombstones reach no purger scan

The gap

Three remote cache tables carry a soft_deleted_at writer on main. No purger scan reads any of the three.

Table Writer Discovery index Scan level
npm_remote_packages NpmRemoteEvictor.EvictNpmRemotePackage, in internal/datastore/npm_remote_eviction.go index_npm_remote_packages_on_ns_id_soft_deleted_at none
container_remote_images ContainerRemoteEvictor.EvictContainerRemoteImage, in internal/datastore/container_remote_eviction.go index_container_remote_images_on_ns_id_soft_deleted_at_id none
maven_remote_packages MavenRemoteEvictor.EvictMavenRemotePackage, in internal/datastore/maven_remote_eviction.go index_maven_remote_packages_on_ns_id_soft_deleted_at none

All three slices exist on main today. The npm writer landed on 2026-08-14, the container writer on 2026-08-18, and the Maven writer with !1574 (merged) on 2026-08-19. The three indexes landed with !1581 (merged) on 2026-08-19, in internal/datastore/migrations/sql/20260819135121_add_remote_cache_keyset_and_discovery_indexes.sql.

Each writer marks the named row and no child row. Each leaves every version, file, manifest and attachment under that row live and unmarked. Each doc comment gives the subtree reap to the lifecycle purger.

LifecycleScanStore carries eight scans on main. Three of them are ScanTombstonedRepositories in internal/datastore/lifecycle_scan.go, plus ScanTombstonedNpmPackages and ScanTombstonedContainerImages in internal/datastore/lifecycle_scan_packages.go. The other five are the version-level and manifest-level scans in internal/datastore/lifecycle_scan_versions.go. None of the eight reads a remote package or image table.

A package-level or image-level mark therefore reaches no sweep. The marked row stays, every row under it stays, and their blob_storage_attachments rows stay. ADR-025 reclaims a blob when its attachment count reaches zero, so an unreaped subtree blocks reclamation with no error and no metric. The #### Eviction markers section of docs/specs/S17-rest-management-api.md names the same state "the permanently uncollectable state".

What closes it

Two pieces of work. A scan alone does not close the gap.

1. Three scan levels. One scan per table, on the index that table already carries, in the shape ScanTombstonedNpmPackages uses. Each scan is namespace-scoped, prunes to one partition, and reads the oldest tombstone first, as the merged levels do.

2. A downward walk from the parent tombstone. An eviction leaves the versions live, so no version tombstone exists under the mark. The version-level scans reach nothing there, and the upward leg from a version has no row to start from. The S20-A plan gives its steps 8 to 13 the per-format reap arms, and each arm reaps a subtree that a scan hands to it. Nothing hands any arm a package-level or image-level remote tombstone. The purger needs a net-new downward walk from that tombstone, and this walk is the larger half of the work.

Reachability

No production caller outside internal/datastore calls any of the three evictors, so no request produces one of these marks yet. !1726 (merged) serves the Maven remote package, version and file deletes. While !1726 (merged) is open, no request path reaches MavenRemoteEvictor. Once it merges, a request produces a maven_remote_packages mark and the gap becomes reachable from outside internal/datastore. Whichever merge request serves the npm and container remote deletes does the same for those two tables.

The gap does not wait for a request to matter. Each of the three evictors is callable from any code in internal/datastore, and the state it writes is unreachable from the moment it commits.

Ownership

Two merged documents give this work to S20-A's author, so this issue records an obligation rather than a new scope decision.

docs/specs/S17-rest-management-api.md, in ## Follow-ups:

S20-A amendment: three eviction scan levels, and the reachable destructive=false branch. Phase 6's evictions write the first package- and image-level remote tombstones, and S20-A's scan inventory stops at version level for the remote tables, reaching a remote package only through its tombstoned versions. The amendment adds the maven_remote_packages, npm_remote_packages, and container_remote_images scan levels, backed by the discovery indexes in Eviction markers, and the remote reap logic S20-A already calls net-new walks from them.

docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md, in its S20-A dependency bullet:

The three remote package/image-level scan levels are an S20-A spec amendment owned by S20-A's author (Spec Follow-ups); Step 2 here lands only their indexes.

The same #### Eviction markers section of the S17 spec states the consequence directly:

The index alone changes nothing: the purger gains the three scan levels with it, the index-and-scan addition Phase 4 made for maven_packages, because S20-A's scan inventory reaches a remote package only through its tombstoned versions and calls a remote package with unmarked children unreachable.

Adjacent, and not claimed here

maven_packages sits in the same position on main, and the S17 follow-up does not name it. MavenPackageStore.SoftDeleteMavenPackage marks the package row and writes no child row, the table carries index_maven_packages_on_ns_id_soft_deleted_at, and no scan level reads that index. Whether a fourth level belongs here is open. This issue claims the three levels the S17 follow-up names.

No numbered step of docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md owns any of this work.


👋 Triage note (automated): This issue has been classified as type::bug. The description documents a clear functional gap: package- and image-level remote tombstones written by the three evictors (npm_remote_packages, container_remote_images, maven_remote_packages) are never picked up by any purger scan, leaving soft-deleted rows and their entire subtrees permanently uncollectable — a broken invariant rather than a missing feature or routine maintenance. The labels Category:Artifact Registry, devops::package, and group::package registry have also been confirmed.

If this classification doesn't look right to you, please feel free to update the labels and let the team know!

Edited by Triage and Label issues