docs(plans): record S20-A row 15 and the entries its step MRs cannot make

What this does

This merge request carries ten plan changes. A step merge request cannot make any of them.

Guardrail 4 in AGENTS.md stops a step merge request from editing the plan file. Parallel steps edit adjacent rows and collide, so the table takes a single writer. No standing table merge request was open for S20-A. !1814 (merged) recorded row 14 and merged.

One file changes: docs/plans/2026-08-11-s20a-lifecycle-closed-beta.md.

Row 15 and step 15's merge order

Row Was Now
15 empty !1838

Row 14 keeps !1812. Rows 16, 17b, 18 and 19 stay empty, because their steps have no merge request yet.

Step 15's Depends on line now states the merge order that guardrail 20 asks for. !1812 (merged) merges before !1838 (merged). Step 15's chunk driver calls the RepositoryReaper that step 14 creates. Step 15's CI edits extend the fault-suite job that step 14 adds. The line names a condition and its event, so it reads correctly before that merge and after it.

The purge job's addressing unit

The spec addressed one purge job to one tombstoned repository, or to one tombstoned artifact subtree. The branch addresses one namespace and one scope. lifecycle.PurgeArgs carries a namespace id and a datastore.PurgeScope.

No document in this repository recorded that change. The plan named the scope field alone, in Decisions this plan makes that the spec leaves open. Its list of corrections did not name the addressing unit.

The operator settled this on 2026-08-21 and kept the branch's shape. !1838 (merged) amends the spec to match. This merge request records the correction in Where the tree contradicts the spec, and moves that section's count from seven to eight.

Two consequences of that unit land with it. The enumeration of the sites !1838 (merged) amends now names the uniqueness paragraph under Purge job and sweep, which is the site the entry paraphrases. And Step 16's two sweep acceptance criteria move off the repository unit onto the wording !1838 (merged) gives the spec. Guardrail 4 makes this merge request the only writer that can align them, and a fixture seeding one repository per namespace passes both criteria at the old unit while covering neither half of the two-per-namespace ceiling.

That section is still the right home after the amendment. Three of the seven entries that were already there record a divergence that an amendment to this spec closes: the soft_deleted_at table count, the string durations in the configuration block, and the maven_packages scan hole. The string durations entry is the exact precedent. The plan follows the tree, and step 2's own merge request amends the spec.

Step 14's accepted smell

AGENTS.md asks a plan to name a cost it accepts knowingly. Step 14's two costs are in !1812 (merged)'s description, and a step merge request cannot move them into the plan.

One of the two moves here. Four of the ten reap pages cannot bind their parent foreign key to an index. A drained npm or Maven hosted level then costs one whole partition slice per chunk to prove empty. The remedy is four non-partial indexes, and no step of this plan adds them. Without this entry, a reader of step 1's index set cannot tell the omission from an oversight.

The cost is stated per index class, not as one figure. npm_versions and maven_versions each carry a non-partial index leading namespace_id, so a drained level costs the namespace's slice of the hash partition. npm_packages and maven_packages carry none, so it costs the whole partition. A working page that returns fewer than limit rows pays the same, because a LIMIT stops a scan early only once it has produced limit rows.

The deferral clause is narrowed to the unmerged steps. Step 1's Files: list does name a migration, and that migration merged in !1608 (merged).

The second cost stays out. The Maven hosted arm is quadratic in packages inside one chunk, but its remedy sits inside MavenPackageReaper. That method's doc comment states the cost, and work item #684 carries the index that closes it. A copy in the plan gives that work item a second site to find when it lands.

Two claims that no step edits the spec

The plan says twice that no spec edit is involved. Both claims are wrong, and !1838 (merged) is not the reason.

Steps 2, 3, 4, 8, 9, 10, 12 and 13 have each amended the spec in their own change set. The first was 6e8107cdb on 2026-08-17, four days before !1838 (merged). The Research Findings sentence has been false since that date.

Step 14 amends the spec too, in 30c867a49 on !1812 (merged). Step 14 makes nine when !1812 (merged) merges, and step 15 makes ten when !1838 (merged) merges after it. Step 15's own Merge order block fixes that order.

The same sentence called two amendments outstanding. Both have landed: the mount criterion in eda85dff9, and the Δ-accounting emit-site allocation in 8d8e096b1.

The Dependencies bullet keeps the half that survives. S17's destructive contract is on main, and step 18 opens on its three in-plan dependencies alone. The heading now says that no step waits on a separate spec MR. That is the dependency claim the bullet was making, and it is true read on its own.

This adds no entry to Where the tree contradicts the spec, so that section's count stays at eight.

The five sentences Step 16 falsifies

Finding O2 from the phase-8 triage. Step 15 ships five statements that no purge attempt runs. Each one speaks to its own reader.

  • PurgeEnqueueOptions' doc comment in internal/lifecycle/purge.go
  • LifecycleConfig's doc comment in internal/config/lifecycle.go
  • the LifecycleConfig message comment in proto/artifactregistry/config/v1/config.proto, whose copy in gen/artifactregistry/config/v1/config.pb.go protoc regenerates
  • the comment above the lifecycle: block in config.example.yaml
  • the lifecycle section of docs/dev/configuration-reference.md

Step 16 registers both job kinds, and that event ends all five at once. Until now no document told the person doing Step 16 where the five are. The note goes in the Step 16 block, because Step 16 owes the rewrite and Step 15 is only where the sentences were written.

The five stay five. The configuration reference is user-facing, and each surface owes its own reader an as-is claim, so the fix is a coordination record and not a collapse.

This adds no entry to Where the tree contradicts the spec, so that section's count stays at eight.

Four Step 15a sentences about a retryable refusal

The plan said the purge job's refusal is retryable. That is false. PurgeEnqueueOptions passes jobsriver.WithMaxRetry(PurgeMaxAttempts), so the job backend discards a refused attempt.

The operator settled this on 2026-08-22 and accepted the step as built. Four corrections land here.

Site Was Now
Step 15a Files:, the purge.go bullet the sentinel is "returned so River retries the job", and !1550 (merged) justifies the retry the attempt fails before any row is reaped, and !1550 (merged) is cited for the refusal-rather-than-skip shape
Step 15a Acceptance: "The refusal is retryable", asserted by "the next attempt" the refusal delays the reap, asserted by a later attempt that drains the same subtree
Naming Conventions, lifecycle.errPurgeNamespaceUnserviceable a "retryable refusal" a refusal to reap
Naming Conventions, a new row absent lifecycle.errPurgeMissingNamespaceRow

The !1550 (merged) citation was an inverted borrow. errBulkNpmNamespaceUnserviceable is retryable because a bulk enqueue takes River's default retry policy. A purge enqueue sets its own. AGENTS.md names this shape in A Justification Cited as a Precedent Must Survive the Precedent Itself. The citation stays for the half it does support, which is the refusal rather than a skip.

errPurgeMissingNamespaceRow is the second sentinel this step introduces. It refuses on a nil namespace row, and it guards the dereference that datastore.WriteServiceable's value parameter would otherwise take. NamespaceStore.FindByID returns a row or an error and never neither, so nothing reaches that arm today. Step 15a's Files: bullet names the symbol, and its Acceptance: records why the sentinel carries no acceptance line of its own.

No corrected sentence states a number for the attempt budget. The Step 15 block introduces no budget, so a number here has no source in the plan. The corrections say what happens instead: the job backend discards the refused attempt, and the re-queue belongs to the sweep that Step 16 creates.

This merge request records row 15a. The Row 15a section gives the reference.

Row 15a

Step 15a's merge request opened, and guardrail 4 makes this merge request the only writer that can record its row.

Row Was Now
15a empty !1868

The fill triggers when the step merge request opens, not when it merges. An empty cell means "not yet recorded", not "not started".

No other row changes. Rows 15 and 17a keep the references that earlier work gave them.

The open-question count

The plan said "Both of the spec's open questions are answered in Approach". That count is a present-tense claim, and !1868 (merged) moves it.

The spec on main carries two bullets under Open Questions. !1868 (merged) adds a third, on ADR-010's write-blocked phase against the purger's write-serviceability gate. "Both" is true today and false after !1868 (merged) merges. A recount to "three" is wrong in the other direction, for the same reason.

AGENTS.md asks for a condition and its event in A Tracking Issue Describes the World Its Reader Will Find. The sentence now names !1868 (merged) and both sides of its merge.

Approach answers neither half of the third question. ADR-010's authors decide it with the spec's author, and the sentence says so.

This adds no entry to Where the tree contradicts the spec, so that section's count stays at eight.

Step 16's wiring guard

Finding C2 from the phase-8 triage. Step 16's block did not say how the lifecycle tier refuses an unusable dependency set.

The purge worker refuses a nil pool inside Work, and returns errPurgeNilDatabase. That refusal arrives once per purge attempt. The merged precedent refuses at boot instead. newChunkWorkerDeps in cmd/artifact-registry/wire_accounting.go takes the pool and the settings as concrete values, and it panics on an unusable one.

The bullet puts the lifecycle tier on that shape. A zero retention_window stays outside the guard. It keeps the startup WARN that the cmd/artifact-registry/main.go bullet already prescribes, because an operator can hold that value.

The tier sets DB and Config alone. Namespaces, Levels and ChunkSize take the defaults of NewPurgeWorker. The typed nil that the PurgeNamespaceFinder seam admits is therefore out of reach through this tier. The bullet also states what a later step that assigns Namespaces must do. That step holds *datastore.NamespaceStore as a concrete pointer, and nil-checks it before the assignment.

The bullet names the test shape too. cmd/artifact-registry/wire_lifecycle_test.go takes a field-count assertion over PurgeWorkerDeps, plus one assertion per field the tier sets. That is the shape of TestNewChunkWorkerDeps_WiresEverySeam. The reflection walk in TestNewBulkDeleteNpmDeps_WiresEverySeam does not fit. It requires every field to be an interface, and four of these five are not. It also reads an interface that holds a nil pointer as wired.

This adds no entry to Where the tree contradicts the spec, so that section's count stays at eight.

The purge-lag bullet's open question

Finding MG6 from the phase-13 triage. In Decisions this plan makes that the spec leaves open, the purge-lag bullet said "The spec's other open question asks three things". The word "other" presupposes that the spec carries exactly two open questions.

!1868 (merged) adds a third. After !1868 (merged) merges, the spec has three open questions, and "other" identifies none of them.

The sentence now names the question by its subject, which is the purge-lag inflation window. It then reads correctly whether the spec carries two open questions or three. The registration-point bullet directly above already names its own question this way, so the two bullets take one shape.

This is the second site of the same count problem, and the two sites take different remedies. The open-question count section covers the first site, under the plan's own Open Questions heading. That sentence carries the count itself, so it names a condition and its event. This sentence points at one question, so a name removes the count claim instead.

This adds no entry to Where the tree contradicts the spec, so that section's count stays at eight.

This is a bot message 🤖 — /smurfit

Edited by Pawel Rozlach

Merge request reports

Loading
Loading