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:
container_remote_manifestshas landed onmainwith itssoft_deleted_atcolumn 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.- Fifteen parent tables carry
soft_deleted_attoday, not fourteen. - The spec's two readings of
container_remote_imagesreachability 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. - The configuration proto uses
stringdurations, notgoogle.protobuf.Duration. Nothing in the tree importsduration.proto. - S17's
destructivecontract is already onmain, 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/accountingdoes 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)