feat(datastore): namespace lifecycle columns and state helpers (S33 Phase 1 Step 1)

S33 Phase 1, Step 1 of the implementation plan: the schema substrate for the GitLab API namespace lifecycle (S33 spec, Data Model).

  • The five ADR-007 lifecycle columns on namespaces (deleted_at, purged_at, blocked_at, disabled_at, suspended_at), all timestamptz NULL, no defaults.
  • The anchor unique index becomes partial (WHERE purged_at IS NULL), so a purged owner can re-onboard with a new row while live and soft-deleted rows keep holding their anchor. unique_namespaces_slug stays total: a purged slug is permanently retired (ADR-015).
  • State helpers in internal/datastore/namespace_state.go: NamespaceStatus (derived status, ADR-007 precedence), WriteServiceable and ReadServiceable (the spec's gating predicates). Free functions outside the generated Jet package so regeneration cannot clobber them. Steps 3-8 consume them; no callers yet.
  • Migration-guide update, paired with the migration that needed it: CREATE INDEX CONCURRENTLY requires a drop-before-create guard, because a failed build leaves an INVALID index that IF NOT EXISTS silently adopts on retry.

Size note: ~760 reviewable LOC, but ~650 of those are tests (unit plus schema/DML integration); the production surface is ~110 LOC.

Review notes:

  • The retry guard keeps one residual window (a crash between the swap's DROP and RENAME leaves the retry rebuilding without a guard). It is documented in the migration and the guide rather than eliminated: the plan and squawk mandate the CONCURRENTLY pattern, and the table is empty pre-production.
  • The Down is data-dependent once purged anchors re-onboard (the total-index rebuild collides); the Down comment tells the operator to resolve duplicate-anchor rows first. A static test pins the Down's index shape, per the migration-test policy.
  • The helpers read a missing projection as active/serviceable. FindByID/FindBySlug do not select the five columns yet; hydration lands with gating enforcement in Step 3.

Spec coverage

Spec: docs/specs/S33-gitlab-api.md

Acceptance criteria

# Criterion Tests
AC-1 Provisioning 201 creates row, returns UUID/slug/status: active Step 5 (provisioning endpoint). Not tested in this MR.
AC-2 Exact anchor replay returns 200, unaltered, no second row Step 5. The race-free anchor guard it relies on is pinned here by TestNamespacesConstraints_AnchorPartialUniquePurgedReonboard.
AC-3 Mismatched replay returns 409, row unchanged Step 5. Not tested in this MR.
AC-4 BLV enabled and unreachable: 503, no row Step 5. Not tested in this MR.
AC-5 Syntactic/reserved/brand-list failure: 422, no row Steps 2 and 5. Not tested in this MR.
AC-6 Slug taken by another namespace: 409 Step 5. The DB guard (total slug unique index, purged slugs retired) is pinned here by TestNamespacesSchema_SlugUniqueIndexStaysTotal, TestNamespacesConstraints_PurgedSlugStaysRetired.
AC-7 Resolution returns anchor/slug/derived status; unknown UUID 404 Step 6. Not tested in this MR.
AC-8 Condition endpoints touch exactly their column; derived status follows ADR-007 precedence Step 7 (endpoints). The precedence derivation is covered here by TestNamespaceStatus, TestNamespaceState_StatusPredicateConsistency.
AC-9 Suspended: download works, push rejected; blocked/disabled/deleted: both rejected Step 3 (enforcement). The predicates are covered here by TestWriteServiceable, TestReadServiceable, TestNamespaceState_StatusPredicateConsistency.
AC-10 Management-surface requests gated by the same predicate table Step 3. Predicate coverage as AC-9.
AC-11 Verifications: 204 all-in, opaque 400 otherwise Step 8. Not tested in this MR.
AC-12 Resolution and conditions work in every state Steps 6 and 7. Not tested in this MR.
AC-13 OpenAPI document defines everything and validates in CI Step 4 (contract rig). Not tested in this MR.

Error cases

# Condition Tests
E-1 Missing/invalid service credential: 401 unauthorized Step 4. Not tested in this MR.
E-2 Malformed JSON body: 400 bad_request Steps 4-8. Not tested in this MR.
E-3 Slug fails validation: 422 unprocessable_entity Steps 2 and 5. Not tested in this MR.
E-4 Slug taken: 409 conflict Step 5 (DB guard pinned here, see AC-6).
E-5 Replay body disagrees: 409 conflict Step 5. Not tested in this MR.
E-6 BLV enabled but unreachable: 503 service_unavailable Steps 4 and 5. Not tested in this MR.
E-7 Unknown namespace UUID: 404 not_found Steps 6-8. Not tested in this MR.
E-8 Unknown <action> segment: 404 not_found Step 7. Not tested in this MR.
E-9 Verifications: unknown/foreign id, empty or oversized batch: 400 Step 8. Not tested in this MR.

Security considerations

# Concern Tests
S-1 Internet-facing surface behind the bootstrap-token stub Step 4. Not tested in this MR.
S-2 Opaque verifications response (no id enumeration) Step 8. Not tested in this MR.
S-3 No organization data leaves the registry Steps 6 and 8. Not tested in this MR.
S-4 Slug trust boundary: CHECK constraints, parameterized queries Steps 2 and 5. The CHECK constraints pre-exist in the schema (S17); not re-tested in this MR.

Step 1 plan acceptance

Acceptance (plan Step 1) Tests
Migration applies and reverts cleanly TestMigrations_UpDownUp (existing, chain-generic per docs/dev/go-testing.md policy)
Five lifecycle columns: nullable timestamptz, no default TestNamespacesSchema_LifecycleColumns
Anchor index partial on purged_at IS NULL TestNamespacesSchema_AnchorUniqueIndexPartialOnPurged
Purged anchor re-insertable; soft-deleted anchor is not TestNamespacesConstraints_AnchorPartialUniquePurgedReonboard
Slug index stays total; purged slug retired TestNamespacesSchema_SlugUniqueIndexStaysTotal, TestNamespacesConstraints_PurgedSlugStaysRetired
Down restores the canonical total index TestNamespacesSchema_DownRestoresTotalAnchorIndex (static SQL parse)
Status precedence order (ADR-007) TestNamespaceStatus, TestNamespaceState_StatusPredicateConsistency
Both serviceability predicates per the gating table TestWriteServiceable, TestReadServiceable, TestNamespaceState_StatusPredicateConsistency

Database Review Evidence

Migrations

Note

Timings are from CI (db:migrate matrix, goose verbose) against an empty database, in apply / rollback order per PG version. Production-scale validation via Database Lab is not yet available. See Database review evidence for the matrix rationale and how to read the numbers.

Migration PG 16 PG 17 PG 18
20260717141847_add_namespaces_lifecycle_columns_and_partial_anchor_index.sql OK (11.16ms / 12.58ms) OK (10.55ms / 13.23ms) OK (12.19ms / 16.57ms)

Related to #202 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading