docs(plans): add the S20-A lifecycle purger plan

What

Adds the implementation plan for S20-A Lifecycle (closed beta), decomposed into 19 numbered steps across 20 MRs, plus its docs/plans/README.md index row.

This is a plan MR. Merging it is the approval gate for the research, approach, and step breakdown, and no step MR opens before it lands.

The container marker schema is S17 Phase 4's, not this plan's

The largest change since the first revision. S17 Phase 4's plan (merged in 959c4ffe) has a section titled Cross-spec ownership of the container marker schema claiming container_images.soft_deleted_at, the partial-index swap, both discovery indexes, and every per-statement predicate as its Steps 5-7. This plan scheduled the same work and inverted the order: it swapped the unique index partial in its Step 2 while upsertContainerImageInsert still named a bare conflict target, so every image push would have failed with 42P10 until its Step 6 landed.

This plan now defers. Its Steps 2, 6 and 7 are gone, and the two steps that still need the column gate on that plan's Steps 5 and 7. Two things are recorded for that plan's reviewer rather than decided here: the /v2/ mount route's verdict, where this spec requires 404 NAME_UNKNOWN and S17 Phase 4's Step 6 acceptance says "missing-source 202 fallback", and the post-swap EXPLAIN on listContainerImagesStmt, whose ordering the swap can silently break.

Decomposition

Four roots are openable the moment this merges: the discovery-index migration, the lifecycle configuration block, the repository-read tombstone filters, and the tombstone write with the parent-gate row lock.

Band Steps
Schema 1, now the plan's only migration
Configuration 2
Tombstone visibility 3-4, repositories only
Purger SQL layer 5-14: the seam, two scan steps, six reap steps (one per format family), then the repository walk and finalizer
Orchestration 15-17b: the internal/lifecycle worker and chunk driver, the sweep and its wiring, the observability
Entry point and accounting 18-19

Steps 6, 8, 9 and 12 are the widest parallel band once Step 5 lands, with 7 and 13 joining once S17 Phase 4's Step 5 does. Steps 10 and 11 follow their hosted siblings rather than the seam, because the npm and Maven attachment guards each span that format's hosted and remote tables.

Sizing

Steps are sized against measured predecessor MRs, not source-only forecasts. S13's 23 merged MRs have a median of 1071 added LOC, S17 Phase 3's 12 have 2310, and S22's 3 have 1733. Every measured S22 step overran its own plan's estimate — 3.1x, 4.4x and 2.9x — and the excess is almost entirely test code. The plan therefore adopts a ~900-LOC working ceiling and says so explicitly, since the harness warns at 500 — that warning is expected on most of these steps. Six steps between 800 and 900 carry recorded negative seam-interrogation outcomes so a reviewer can see the question was asked.

Where the tree contradicts the spec

Recorded in Research Findings rather than substituted silently, per the repository convention. Beyond the S17 Phase 4 ownership finding above:

  1. container_remote_manifests has landed on main with its soft_deleted_at column and its discovery index, so S16's gate on the fifth version-level scan is already lifted. Its blobs, tags and relationship tables are still absent and stay out of scope.
  2. Fifteen parent tables carry soft_deleted_at today, not fourteen.
  3. The spec's two readings of container_remote_images reachability disagree — the bound table gives it no scan, the reachability table lists it as scannable. The plan follows the bound table, and does not lean on "no writer today" as the reason that is safe, because the spec forecloses exactly that argument.
  4. The configuration proto uses string durations, not google.protobuf.Duration. Nothing in the tree imports duration.proto.
  5. S17's destructive contract is already on main, so no spec edit rides this plan.

Two CI findings also change a step's contents. test:integration selects packages from a hardcoded list and gates on a hardcoded changes: anchor, so internal/lifecycle has to join both or its five integration suites compile, report green, and never run — the job's own comment warns about this case. And internal/managementapi/contract_routes_test.go does not break on a required query parameter: it asserts only NotEqual(501) plus a content-type check, which a 400 passes.

Three external gates

Neither S22 gate has a schedule fallback, and the risk is stated in the plan's Dependencies rather than hedged:

  • S17 Phase 4's Steps 5-7 gate this plan's Steps 7 and 13. The gate is narrower than it looks: those three MRs chain only to each other and have no other dependency inside their own plan, so all three can open the day this merges. It is still on the critical path.
  • Step 17b (the leased tombstone-count gauge) waits on S22's leased-collector step so the tree carries one lease implementation rather than two. Splitting Step 17a out is what keeps the purge-outcome counter and both histograms off that gate — a purger shipping without the gauge is now observable rather than silent.
  • Step 19 (the two emit call sites) waits on S22's emit-API step. internal/accounting does not exist yet. Steps 1-18 do not reference the emit API.

S22 has three of twenty MRs merged and closed beta is 2026-09-07, so closed beta may still launch with the purger running and no fleet-wide tombstone count, which is the spec's stuck-purge control.

Verification

markdownlint-cli2, lychee --offline --include-fragments, and vale all pass. Mechanically checked: the Mermaid DAG's 20 nodes and 28 edges match the per-step Depends on: lines and the dependency table in both directions; the LOC table's step ids match the section ids; every row's source + tests = total; and the six steps in [800, 900] are exactly the six the interrogation section lists.

Related to #464 (closed)

Edited by Pawel Rozlach

Merge request reports

Loading
Loading