chore(lifecycle): namespace write-serviceability gate on the purge path (S20-A plan: 15a/21)
What this step delivers
The purge job refuses to reap in a namespace that does not serve writes.
PurgeWorker.Work reads the namespace row once for the attempt, then evaluates datastore.WriteServiceable over it.
An attempt whose namespace carries any one of the five lifecycle columns fails before the first discovery scan.
The tombstone and every row under it stay where the attempt found them.
A read that fails, and a read that answers neither a row nor an error, also fail the attempt.
The gate is fail-closed: a read that produced no verdict permits no reap.
Each of the gate's three refusal arms logs one line, and each line carries namespace_id as a field.
The frozen-namespace arm also names the condition that froze it, in a namespace_status field.
The section Two implementation choices, named below states where that value comes from and what it collapses.
The read enters through a new seam, PurgeNamespaceFinder.
NewPurgeWorker defaults that seam to datastore.NewNamespaceStore over the pool, so the composition root sets DB and Config alone.
This step changes nothing in the service.
No package outside internal/lifecycle imports the gate, and the binary does not link it.
go list -deps ./cmd/... names no lifecycle package.
The one file this branch adds to outside that package is internal/testutil/namespace_lifecycle.go, which carries //go:build integration and reaches no production wiring path.
Step 16 registers the worker and builds the composition root, and the gate goes live at that point.
That is what keeps this step a chore and leaves the feat guardrails on Step 16.
One fact for the reviewer of the Step 16 wiring MR.
The gate costs one namespace read per attempt, and a composition root that leaves Namespaces nil inherits the NewPurgeWorker default.
Spec coverage
Spec: docs/specs/S20-a-lifecycle-closed-beta.md Design section: A frozen namespace is not reaped
The spec holds 59 acceptance criteria across seven groups. The Purger and discovery group, where this step operates, is enumerated to the criterion; each of the other six groups carries one row naming the step that owns it, so no criterion is unaccounted for.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | retention_window eligibility, asserted non-zero and at zero |
Step 15. TestPurgeWorker_Work_ALengthenedRetentionWindowLeavesTheTombstoneUnreaped, with the scan half in the internal/datastore scan suites. Unchanged here |
| AC-2 | A soft_deleted_at IS NULL row is never a purge root, for every table |
Step 15 and the internal/datastore scan suites. Unchanged here |
| AC-3 | EXPLAIN on the repositories, npm_packages and container_images scans |
internal/datastore scan suites. Unchanged here |
| AC-4 | EXPLAIN on the five version-level scans |
internal/datastore scan suites. Unchanged here |
| AC-5 | Re-run on a reaped subtree is a no-op; an interrupted walk leaves fewer rows and no marker | Step 15. TestPurgeWorker_Work_DuplicateFireOnAReapedNamespaceReportsSuccess, TestPurgeWorker_Work_AnInterruptedWalkLeavesFewerRowsAndNoMarker. Unchanged here |
| AC-6 | A walk takes live and already-tombstoned npm rows in one pass | Step 15. TestPurgeWorker_Work_ReapsATombstonedRepositoryAndLeavesALiveNeighbour. Unchanged here |
| AC-7 | Rows written per transaction do not grow with the repository's contents | Step 15. internal/lifecycle/chunk_integration_test.go. Unchanged here |
| AC-8 | A purge job whose namespace stopped serving writes between enqueue and run refuses before any row is reaped, so the tombstone and every row beneath it are still there and a later attempt reaps them once the condition lifts. Asserted for each of the five lifecycle columns set on its own | This step. Projection half, against a real read: TestPurgeWorker_Work_RefusesToReapInANamespaceThatDoesNotServeWrites — five subtests, one per column, each refusing with the subtree intact and then reaping it after the column clears. Scope half: TestPurgeWorker_Work_AFrozenNamespaceRefusesAnArtifactScopedAttemptToo. Whole-verdict half, without a database: TestPurgeWorker_Work_RefusesBeforeAnyScanWhenTheNamespaceDoesNotServeWrites — five subtests, each also asserting no scan ran. Positive contrast: TestPurgeWorker_Work_AServiceableNamespaceReachesTheScan. Seam wiring: TestNewPurgeWorker_ANilNamespaceFinderTakesTheStoreOverAPool |
| Per-format reap logic (12 criteria) | npm, Maven, container and remote-cache reap behaviour | Steps 7 through 15 and the internal/datastore reap suites. Not this step |
| Repository entry point (10 criteria) | DELETE semantics, destructive, name reuse |
Steps 18 through 20. Not this step |
| Sweep (8 criteria) | Includes the sibling of AC-8: a namespace that does not serve writes has none of its eligible tombstones enqueued, and is re-queued on the first tick after its condition clears | Step 16. Not this step: no sweep exists on this branch, so the row is uncovered here by design |
| Tombstone visibility (12 criteria) | Tombstoned rows hidden from resolvers and routes | Steps 8 through 13. Not this step |
| Accounting call sites (5 criteria) | Δsize and Δcomponents emission |
Step 19. Not this step |
| Schema and configuration (4 criteria) | Columns, indexes, lifecycle config section |
Steps 1 through 6. Not this step |
Error cases
| Condition | Behavior | Tests |
|---|---|---|
Purge job: 23503 on a parent delete |
Transaction aborts, job fails, never ErrRepositoryNotEmpty |
Step 15. TestPurgeWorker_Work_APinnedParentFailsTheJobRatherThanReportingNotEmpty. Unchanged here |
Purge job: ContainerManifestDeleter refuses a manifest as still referenced |
Work-ordering error, retried | Steps 12 and 15, internal/datastore. Not this step |
| Purge job: the finalizer aborts because an artifact remains | Job fails, tombstone stands | Step 15, internal/datastore. Not this step |
| Purge job: repository already hard-deleted when the job runs | Success; the ordinary duplicate fire | Step 15. TestPurgeWorker_Work_DuplicateFireOnAReapedNamespaceReportsSuccess. Unchanged here |
| Purge job: retention window changed between enqueue and run | Success having reaped nothing | Step 15. TestPurgeWorker_Work_ALengthenedRetentionWindowLeavesTheTombstoneUnreaped. Unchanged here |
| Sweep: enumeration query fails / interval elapses before enumeration completes | Tick fails or the remainder waits for the next tick | Step 16. Not this step |
Repository DELETE rows (7 conditions), parent-gate row, republished-coordinate row, emit-API row |
See the spec's Error Cases table | Steps 8 through 20. Not this step |
| Purge job: the namespace stopped serving writes between enqueue and run | Not in the spec's Error Cases table. The behaviour is fixed by A frozen namespace is not reaped and by AC-8: refuse before any row is reaped, one attempt, no retry by the job backend | This step, as for AC-8 above. Flagged as a spec gap: the table carries five other Purge job: rows and not this one |
| Purge job: the namespace read fails, or resolves to no row | Not in the spec at all. Chosen reading, fail-closed: the attempt fails and reaps nothing, and the store's own cause stays matchable | TestPurgeWorker_Work_RefusesWhenTheNamespaceCannotBeResolved — three subtests: the read failed, the namespace is absent, and the read answered neither a row nor an error |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Authorization unchanged; the purger holds no request context and no caller identity | Structural: PurgeWorker takes neither. Not newly tested here |
| S-2 | destructive is a confirmation, not an authorization control |
Steps 18 through 20. Not this step |
| S-3 | Deletion is irreversible in closed beta and the API must say so | Documentation acceptance of the API step. Not this step |
| S-4 | Input validation; no purger input is user-controlled | Step 15. TestPurgeArgs_DecodeRejectsAScopeOutsideTheDefinedSet, TestPurgeArgs_DecodeRejectsAPayloadOutsideTheArgumentShape. Unchanged here |
| S-5 | No injection surface is added | The gate adds no SQL of its own: it reads through datastore.NamespaceStore.FindByID and evaluates datastore.WriteServiceable in Go. Asserted indirectly by TestNewPurgeWorker_ANilNamespaceFinderTakesTheStoreOverAPool, which pins the store as the seam's default. The fixture SQL this branch adds is one more entry shape on internal/testutil's fixed allowlist, and it takes a state name rather than a column name |
| S-6 | Error messages must not become an existence oracle | Steps 8 through 13 and 18 through 20. Not this step. The refusal added here names no identifier: its text is a fixed sentence, and the namespace id travels in the log fields instead |
| S-7 | Purge failures are operationally visible rather than silent; a purge that cannot complete must never report success | This step, for the frozen-namespace case. Every refusal subtest asserts the attempt fails rather than reporting a clean pass, which is what leaves an operator a failed attempt to read. All three refusal log lines are asserted too, because the sentinels name no identifier and the line is what carries the namespace as a field: TestPurgeWorker_Work_RefusesBeforeAnyScanWhenTheNamespaceDoesNotServeWrites pins the WARN level, its message, namespace_id and namespace_status, per column rather than for one representative; TestPurgeWorker_Work_ANilNamespaceRowIsRefusedAtErrorNamingTheNamespace pins the nil-row ERROR arm; and TestPurgeWorker_Work_AFailedNamespaceReadIsRefusedAtErrorNamingTheNamespace pins the read-failure ERROR arm, which review of this merge request added. The tombstone-count gauge half is Step 17b |
Resolutions
| Entry | Tests |
|---|---|
The purger reaps only in a write-serviceable namespace, on datastore.WriteServiceable's whole verdict rather than on a subset of the five conditions |
This step. The whole-verdict claim is what the five one-column subtests of TestPurgeWorker_Work_RefusesBeforeAnyScanWhenTheNamespaceDoesNotServeWrites pin: a gate re-spelling a subset passes one column and fails the others. The projection claim is what the five subtests of TestPurgeWorker_Work_RefusesToReapInANamespaceThatDoesNotServeWrites pin against a real read. Note: this bullet sits in the spec's Resolutions section, not in Security Considerations |
Notes on the spec-coverage table
The table comes from test(lifecycle): pin the purge path's write-serviceability gate, the commit at the foot of this branch. Its acceptance-criteria, error-case and resolution rows all hold at the branch tip. Commits are named by title rather than by SHA throughout this description, because the branch has been rebased and force-pushed and a SHA written here goes stale before a reader can check it. !1867 (merged) and !1838 (merged) both state that rule in their own descriptions, for the same reason.
The six tests those rows name for this step exist. Both five-column suites carry five subtests, and the not-a-row suite carries three.
Three rows were edited past the version that commit produced, and each edit is named here rather than left for a reader to find. test(lifecycle): pin the purge gate's two refusal log lines added the first two log-line assertions, and the S-7 row said nothing about them. chore(lifecycle): log the namespace when the purge read fails added the third, in review of this merge request. test(lifecycle): freeze the purge namespace through the shared helper moved the integration fixture onto the shared helper, and the S-5 row said nothing about the fixture SQL.
Three tests on the branch appear in no row of the table.
TestPurgeWorker_Work_ANilNamespaceRowIsRefusedAtErrorNamingTheNamespace. Its subject is the log record rather than an acceptance criterion, so S-7 is where it is recorded. It holds the nil-row ERROR line apart from the WARN arm's, and nothing else in the record separates the two.TestPurgeWorker_Work_AFailedNamespaceReadIsRefusedAtErrorNamingTheNamespace, added in review of this merge request. Same subject and same reason: S-7 records it.TestPurgeWorker_Work_ReadsTheNamespaceOnceAcrossEveryDispatchedLevel, added by test(lifecycle): pin the namespace read at once per attempt. Its subject is a statement cost rather than a criterion: it pins the read at once per attempt, which is whatPurgeWorker.Work's own comment states. No spec criterion asks for it, so it gets no row.
Four commits on this branch edit the spec, and the table predates all four.
docs(specs): open the ADR-010 write-blocked question on the purge gate adds a bullet to the spec's ## Open Questions, on ADR-010's write-blocked phase against this gate.
docs(specs): correct the ADR-010 open question's crux and answer shapes then corrects two lines of that bullet against the code and the schema.
Review of this merge request added the other two: docs(specs): track what a deleted namespace's frozen rows need and the pair docs(specs): record the ADR-007 deviation the purge gate carries with docs(specs): link the ADR-007 amendment the purge gate owed.
The section The spec ## Open Questions bullet this branch adds states what the first bullet records, and The ADR position states the rest.
AC-8 is not fully closed by this step. Its clause "a later attempt reaps them once the condition lifts" has no production driver on this branch, because the sweep belongs to Step 16. The integration suite drives the second attempt directly.
The deviation from the plan's approved Acceptance line
The plan's Step 15a Acceptance: line, as approved, read: "The refusal is retryable, asserted by clearing the column and observing the next attempt reap the same subtree."
This step does not satisfy that line, and the operator accepted the deviation.
That claim is superseded: the plan on main no longer says it, and the paragraph below names what replaced it.
PurgeMaxAttempts is 1, so there is no next attempt.
The job backend discards the refused attempt, and the re-queue belongs to the sweep that Step 16 creates.
The step was built to the spec, whose section A frozen namespace is not reaped says that "the job backend discards the refused attempt rather than retrying it".
No test, test name, comment, or commit sentence on this branch claims a backend retry.
The clear-and-re-run mechanism survives, in TestPurgeWorker_Work_RefusesToReapInANamespaceThatDoesNotServeWrites.
That suite drives the second attempt directly, and it is evidence of a temporary refusal rather than of a River retry.
The deviation is closed, and this section is now the record of it rather than a live disclosure.
!1840 (merged) carried the plan corrections and merged on 2026-08-24.
The Step 15a Acceptance: line on main now reads "The refusal delays the reap rather than cancelling it, asserted by clearing the column and running a later attempt that drains the same subtree the frozen one refused", which is what TestPurgeWorker_Work_RefusesToReapInANamespaceThatDoesNotServeWrites does.
The plan, the spec, the code and this description agree.
The section is kept rather than deleted because it records that an operator accepted a deviation from an approved acceptance line, and the squash discards this description at merge, so nothing else carries that.
Files touched beyond the plan's Files: list
The plan names internal/lifecycle/purge.go and internal/lifecycle/purge_integration_test.go.
Six more files are in the diff.
| File | Why |
|---|---|
internal/lifecycle/purge_test.go |
The whole-verdict cases, the three unresolvable-namespace cases, the nil-row log case and the read-count case need no database |
internal/lifecycle/purge_internal_test.go |
deps.Namespaces is unexported, so the constructor default is observable only inside the package |
internal/lifecycle/chunk_integration_test.go |
Two base cases drove an attempt over a namespace id with no row. The gate refuses them at the read, ahead of their own subject. Each case now seeds a namespace, and no assertion changed |
internal/lifecycle/metrics_integration_test.go |
Four base cases had the same problem, for the same reason. Each now seeds a namespace. The tombstone rows stay literal, because what these cases read is the mapping and the counter rather than the discovery scan, and no assertion changed |
internal/testutil/namespace_lifecycle.go |
The five frozen-namespace subtests enter a lifecycle state and then leave it. The shared helper held the set direction only, so ClearNamespaceLifecycle joins it and the statement map now carries both directions in one entry per state |
docs/specs/S20-a-lifecycle-closed-beta.md |
The ## Open Questions bullet, routed to this merge request by the operator, plus the ## Follow-ups and ## Resolutions entries review of this merge request added |
Twelve base cases gained a namespace for the same forced reason.
Four sit in purge_integration_test.go, four in metrics_integration_test.go and two in chunk_integration_test.go, and each now calls testutil.SeedNamespace where it called uuid.New.
Two sit in purge_test.go, and each now stubs the namespace read with a serviceable row.
Every one of the twelve carries a comment saying why.
Counted as added testutil.SeedNamespace call sites, purge_integration_test.go shows six rather than four: the other two belong to the frozen-namespace suites this step adds rather than to a base case.
Eleven of the twelve keep the assertions they had.
TestPurgeWorker_Work_RefusesALevelMissingItsScanOrItsReaper is the exception, and its case table gained a wantReads field.
The suite now asserts the stub's read count on every case.
That count says a short dispatch is refused without a read spent on it.
Its error assertion moved into the new else arm as well, and from assert.EqualError to require.EqualError.
The spec edits are deliberate and the operator approved each one.
Guardrail 4 bars the plan file only, so a step MR can edit the spec.
Steps 14 and 15 both carry docs(specs) commits, so the shape has precedent inside this plan.
The spec ## Open Questions bullet this branch adds
ADR-010's ### Subscription Expiration table says of its write-blocked phase that retention policies and lifecycle policies continue to run.
This gate refuses to reap while suspended_at is set, and S33 calls that column billing suspension with read-only service.
The new bullet records the question as a condition and its event rather than as a present-tense conflict.
The two rules do not conflict today, for two measured reasons.
ADR-010's ### Deletion Precedence table lists three deletion triggers, and the write-blocked row names only the second and third.
The purger reaps tombstones a user wrote, and that is the trigger the write-blocked row leaves out.
Neither named mechanism exists in the tree: a search of the Go sources returns one hit, a comment that calls those flows future work.
The bullet names what changes that. Once a policy-driven tombstone writer lands, the purger becomes the reap path for policy deletions too, and the write-blocked row then reaches this gate directly. The bullet fills in its Shape of the answer and Decides fields, as the other two bullets under that heading do.
docs(specs): correct the ADR-010 open question's crux and answer shapes corrects two sentences of the bullet. They are named rather than numbered, because the bullet's line numbers moved twice more after that commit.
The crux sentence said that a suspended namespace stops the policy runs ADR-010 says continue.
The gate sits in PurgeWorker.Work and refuses the reap, and a policy engine writing tombstones is a different writer that the gate does not touch.
It now says what the gate does: the policy run continues and writes its tombstone, and the reap that deletion depends on is what stops.
The second answer shape, policy-driven reaps running under suspended_at, needs the purger to tell a policy-driven tombstone from a user-written one. That cost is now stated: the only input is soft_deleted_at, and neither the discovery scans nor datastore.TombstoneRow carries the trigger.
The Decides line is unchanged.
e2e scenario catalogs
No scenario is added, and none is affected.
Guardrail 12 puts the obligation on feat and fix work, and this step is a chore, so this paragraph is the statement it asks for.
The purger appears three times in docs/testing/e2e/maven.md and docs/testing/e2e/npm.md, always as background to a soft-delete or a version-cap assertion.
None of them has the purger as its subject, and none drives a namespace that does not serve writes.
No e2e path reaches the worker while it is unregistered.
The gate delays a reap in a held namespace and changes nothing else, so no row's expected outcome moves.
Diff size, and why a split does not help
8 files, +1040 −63, three-dot against main.
That is past the 500 reviewable-LOC threshold, so guardrail 18 and docs/dev/development-model.md ask for a split or a justification.
| Group | Files | Lines |
|---|---|---|
| Production | internal/lifecycle/purge.go |
+175 −21 |
| Tests | purge_test.go, purge_integration_test.go, purge_internal_test.go, metrics_integration_test.go, chunk_integration_test.go |
+781 −34 |
| Test support | internal/testutil/namespace_lifecycle.go |
+57 −8 |
| Spec | docs/specs/S20-a-lifecycle-closed-beta.md |
+27 −0 |
Every file in the diff sits in exactly one group, and the groups sum to the headline: 175 + 781 + 57 + 27 = 1040, and 21 + 34 + 8 = 63.
A split does not help here. The production change is one gate in one function. Of its 175 added lines, 133 are comment and 10 are blank, which leaves 32 lines of code. The tests and the fixture they drive are 80 percent of the added lines, and the verdict is why. It reads five columns, and the spec asks for each column on its own, as a positive hit. Each column therefore appears twice. The real-read suite catches a projection that dropped one column, which fails open on exactly that column. The database-free suite pins the whole verdict, and it needs no fixture. That suite also pins the WARN line per column, because the derived status differs per condition. A split by file group separates the gate from the cases that pin it, and neither part is then reviewable on its own.
The target is main, so the headline figure is already the against-target measurement and there is no second one to quote.
Merge order
This merge request targets main.
The two merge requests it once waited on have both merged: Step 14's !1812 (merged) on 2026-08-23, and Step 15's !1838 (merged) on 2026-08-23. PurgeWorker.Work, PurgeWorkerDeps, checkPurgeLevels and the attempt-timeout fallback, which the gate reads, are all on main as a result.
This merge request is now a stacked base rather than a stacked head, and two merge requests sit above it.
| Order | Merge request | Step | Target |
|---|---|---|---|
| 1 | this merge request | 15a | main |
| 2 | !1871 (merged) | 16 | this branch |
| 3 | !1885 | 18 | !1871 (merged)'s branch |
Review of this merge request force-pushed this branch, so !1871 (merged) needs a rebase onto the new head before its own diff reads correctly.
After !1838 (merged) merges, retarget this merge request to main.
The plan Status row, and the corrections this step owes the plan
Row 15a is not in this change set, and it does not need to be.
Guardrail 4, as merged, reads: "Step MRs do not edit the plan file, the Status table included."
!1840 (merged) carried the row and the prose corrections together, and it merged on 2026-08-24, so row 15a on main reads | 15a | Namespace write-serviceability gate on the purge path | !1868 |.
One plan sentence this step falsifies is still open, and it is disclosed here rather than corrected here.
Step 15a's Acceptance: block on main says that errPurgeMissingNamespaceRow carries no acceptance line "because no fixture can drive it: FindByID never answers a nil row with a nil error, and PurgeWorkerDeps holds a concrete *postgres.Client rather than a store interface a fake could answer through, so there is no seam to inject the answer at either".
Both clauses are false against this step: PurgeNamespaceFinder is that seam, PurgeWorkerDeps.Namespaces holds it, and the third case of TestPurgeWorker_Work_RefusesWhenTheNamespaceCannotBeResolved drives exactly that answer through it.
The correction is requested on !1886 (merged), the open docs(plans) merge request for this plan, in note 3725270164.
The same note raises the Step 15a Files: block, which names two files where this merge request touches eight.
The ADR position
ADR-007's serviceability-predicates bullet lists "scheduled jobs" among the callers that take the subscription-lifecycle predicate.
This gate applies the write predicate, which requires all five lifecycle columns NULL.
The code ships that way by decision, so a reviewer who reads that bullet literally finds the answer here and does not raise it again.
The predicate follows what the lookup does, not what runs it.
A reap is a mutation.
The spec argues that case in A frozen namespace is not reaped, and does not assert it.
Three merged River workers in internal/managementapi already gate on the write predicate, so the literal reading puts merged code outside the ADR too.
The amendment is open: handbook!20869, Artifact Registry ADR 007: a scheduled write is a write-serving lookup.
It narrows the bullet's "scheduled jobs" to scheduled lookups that only read subscription state, and states that a background job which deletes or mutates rows is a write-serving lookup.
It goes to the handbook repository, because the local docs/adr/ mirror is not the authority.
Review of this merge request opened it, after a reviewer marked the missing amendment blocking.
The deviation is also recorded in the spec, which the squash keeps and this description does not.
## Resolutions names the bullet and says the gate stands and the ADR changes; ## Follow-ups links the amendment, beside the two ADR-007 index amendments this spec already discharged.
Unlike those two, this amendment does not gate the code: they settled an index set the DDL was written against, where this one records the reading merged code already follows.
ADR-010 gets no amendment: its write-blocked row describes mechanisms with no implementation, and the spec's new Open Questions bullet carries that half instead.
Review findings answered on this branch
Commits are named by title, for the reason Notes on the spec-coverage table gives.
| Commit | What it answers |
|---|---|
| test(lifecycle): enforce the namespace id in the purge gate's stub | stubNamespaceFinder answered its arranged row whatever id arrived, so an attempt that read the verdict off the wrong identifier left the whole database-free suite green. The stub now answers the store's own not-found on a mismatch. The guard requires a non-nil row, so the three failure arms work unchanged |
| docs(lifecycle): state the namespace seam's identifier obligation | PurgeNamespaceFinder's doc stated one obligation, the five-column projection, and this added a second: a read error had to name the namespace. Review of this merge request then removed that second obligation, for the reason the Two review notes weighed section below records |
| test(lifecycle): freeze the purge namespace through the shared helper | The frozen-namespace cases carried a local freezeNamespace and thawNamespace pair, which built the UPDATE with a column name in a string. Both directions now run through internal/testutil.SetNamespaceLifecycle and the new ClearNamespaceLifecycle, keyed by state name. No assertion moved |
| docs(lifecycle): correct the nil-row and zero-namespace claims | Two false claims in comment text. errPurgeMissingNamespaceRow and its test case said that a dereference of the nil row takes the worker's process down. River recovers a worker panic in JobExecutor.execute, and internal/jobsriver/errorhandler.go logs it as river job panic, so one job fails and the process stands. errPurgeZeroNamespace's second paragraph described the path before the gate, and the gate's read now runs ahead of the scans it named |
| test(lifecycle): pin the namespace read at once per attempt | No case separated one namespace read per attempt from one read per level, because every other case dispatches a single level. TestPurgeWorker_Work_ReadsTheNamespaceOnceAcrossEveryDispatchedLevel dispatches two levels over one stub finder and asserts the read count is 1. Both scan counts are asserted beside it |
| docs(specs): correct the ADR-010 open question's crux and answer shapes | Two lines of the new spec bullet. The section The spec ## Open Questions bullet this branch adds states both corrections |
Six further commits landed in review of this merge request.
| Commit | What it answers |
|---|---|
| test(lifecycle): wrap the stub namespace read as the real store does | The two read-failure cases arranged an identifier-free wrap, so the fixture modelled a store no real read produces. The wrap is now the stub's on both failure arms |
| docs(specs): track what a deleted namespace's frozen rows need | Nothing reclaims what a namespace marked deleted_at still holds, and no work item tracked the gap. Spec ## Follow-ups now points at #828 |
| chore(lifecycle): log the namespace when the purge read fails | The read-failure arm logged nothing, and the read now runs ahead of every discovery scan, so internal/lifecycle recorded nothing at all for a pool outage. The arm logs at ERROR with namespace_id, and the seam's identifier obligation comes off |
| docs(specs): record the ADR-007 deviation the purge gate carries | The deviation lived only in this description, which the squash discards. Spec ## Resolutions now records it |
| docs(specs): link the ADR-007 amendment the purge gate owed | Links handbook!20869 from spec ## Follow-ups |
| docs(lifecycle): correct the purge refusal's re-queue reason | Two sites said the re-queue needs a failure to read. The sweep probes every namespace on every tick, and the spec says so |
Two review notes weighed and not acted on
A reviewer who finds either of these again deserves to know it was weighed.
The seam's identifier obligation against the identifier-free rule — superseded
This subsection recorded that PurgeNamespaceFinder's doc asked an implementation's read error to name the namespace, that docs/dev/database-query-patterns.md asks the opposite under Identifier-free error strings, and that the operator kept the sentence after weighing the tension.
That disposition is spent, and the operator superseded it in review of this merge request.
What changed is not the identifier rule but the scan-line pre-emption: the namespace read now runs ahead of every discovery scan, so a pool outage that once produced one ERROR per dispatched level produced nothing at all from internal/lifecycle.
The earlier weighing answered which arm carries the identifier and in what. It did not answer whether the package should record nothing for the incident, because before this step it could not.
chore(lifecycle): log the namespace when the purge read fails is what replaced it. The arm logs at ERROR with namespace_id as a field, and the obligation sentence comes off the seam, so the seam no longer asks an implementer for what the store guide forbids a new store method.
The commit message of test(lifecycle): freeze the purge namespace through the shared helper
One clause of that body overstates the consolidation: "every other serviceability-gated suite in the tree drives its fixture through it".
TestBulkContainerWorkerIntegration_SuspendedNamespace_RetriesThenApplies, in internal/managementapi/bulk_container_worker_integration_test.go, is a serviceability-gated suite and it inlines its own UPDATE.
The clause reaches no permanent surface. The project squashes on merge, this merge request has squash on, and the squash message replaces every commit body, so that text never reaches the base branch. Correcting it needs an amend, and an amend is barred on this branch.
The step 17a region overlap, now settled
This section carried a hunk-level region map so that step 17a's author could check the operator's ruling that the two steps keep disjoint regions in the two files they share. It is deleted rather than corrected.
!1867 (merged) merged on 2026-08-23, this branch rebased over it, and the predicted internal/lifecycle/purge_internal_test.go conflict resolved keep-both, exactly as !1867 (merged)'s own Files step 15a also works section predicted.
That section is on main and carries the operator's ruling, so deleting this half loses no record.
Every line number the map held moved when !1867 (merged)'s 134 added lines landed in purge.go, and they would move again on the next rebase, which is why the map is gone rather than renumbered.
Two implementation choices, named
The two refusal levels
The nil-row arm of the gate logs at ERROR, and the unserviceable arm logs at WARN. The two differ on purpose.
The WARN comment states its own premise: nothing is broken, the namespace is held, and every attempt refuses again until the platform releases it.
That premise is false on the nil-row arm.
A seam that answers neither a row nor an error is a defect in the seam, not a state the namespace is in.
Nothing the platform does next clears it.
The other four log lines in purge.go are ERROR, so this WARN is the file's one exception.
The attempt's own severity is elsewhere, on the job backend's discard line, which internal/jobsriver/errorhandler.go logs at Error under river job discarded.
That is what lets the gate's level describe the namespace's state rather than the attempt's.
The namespace_status field on the WARN line
The WARN line carries namespace_status, derived by datastore.NamespaceStatus over the row the gate already holds.
Without it the whole record of a refusal names the namespace that stopped and never says why.
The five lifecycle columns have different owners, and only some of them ever lift.
The sentinel names no condition either, so the line is the only place the condition can travel.
The field carries the derived status rather than the five raw columns.
datastore.NamespaceStatus is the one derivation in the tree, and internal/gitlabapi/resource.go fills its status field from the same call.
A second spelling of the five-column rule inside internal/lifecycle drifts from that surface as soon as either side changes.
The derivation collapses by precedence, and that limitation is stated in the arm's own comment.
A namespace that is both blocked and suspended reports blocked.
That is the higher-severity condition, and it is the one whose owner an operator needs first.
It is also the value internal/gitlabapi answers for the same namespace, so the line is consistent with that surface rather than lossy against it.
The key is a file-scope constant, logKeyNamespaceStatus, beside logKeyNamespaceID.
docs/dev/logging.md asks components to pin the keys they emit as file-scope constants, under Component-specific keys, and the constant is that ask.
Its What not to log list bars high-cardinality fields and dynamically generated field names.
datastore.NamespaceStatusValue has six values and one fixed key, so it is neither.
The merged precedent for a categorical key on an operator line is logKeyCounterColumn in internal/accounting/reconcile_task.go, which names the drifting counter column on a drift line.
Why the field is here and not in sibling step 17a
Sibling step 17a owns the purger's signals, and its merge request !1867 (merged) merged on 2026-08-23.
A reader can ask why the condition is a log field here rather than a metric label there.
The answer is structural, and it is stated by symbol because the line numbers moved when !1867 (merged) landed.
internal/lifecycle carries one observePurge call, inside the per-row loop of purgeLevel.
purgeLevel has one call site, inside the level loop of Work.
A frozen-namespace refusal returns from Work ahead of that loop, so it reaches none of the three signals observePurge records into.
17a records such a refusal only after it grows an attempt-level observation, and it carries none.
Issue link
The step card gives this step the type chore.
docs/dev/conventions.md makes the Related to <issue URL> line optional for maintenance and toil, so this description carries none.
!1838 (merged) states the same exemption and carries no link.
The exemption is stated rather than left silent.
This is a bot message