feat(namespace): enforce serviceability gating at slug resolution

What

Step 3 of the S33 Phase 1 plan: slug-keyed namespace resolution on the client protocols (container/OCI, npm, Maven) and the management API now enforces the S33 spec serviceability predicates.

  • Blocked, disabled, deleted, and purged namespaces answer every request with the surface's existing unknown-slug 404 (existence-hiding; the tests pin envelope equality against the unknown-slug baseline).
  • Suspended namespaces serve reads and reject mutating methods (POST/PUT/PATCH/DELETE) with 403 via the namespace.ErrNamespaceSuspended sentinel, mapped through each surface's existing error vocabulary (RFC 9457 forbidden on Maven, S01 forbidden on npm and management, DENIED on OCI). The gate fires before request-body parsing.
  • The UUID-keyed FindByID path stays ungated (GitLab API exemption; the repair path must see every state), pinned by its own integration test.
  • The FindByID/FindBySlug projections now carry the five lifecycle columns through the namespaceColumns() constructor — a projection that omits them decodes as NULL and silently reads every namespace as serviceable.
  • The suspended flag derives from datastore.WriteServiceable at resolution, so the lifecycle predicates keep a single home in namespace_state.go.

Stacked MR

Targets jdrpereira/s33-phase1-step-1 (!1010 (merged)) so the diff shows only this step's commits; Step 1's lifecycle columns and state helpers are predecessor code. When !1010 (merged) merges, GitLab retargets this MR to main.

Review notes

  • Size: ~1,510 LOC total: 1,156 tests and ~350 of production Go; the bulk is the five per-surface integration suites (management, npm, Maven, OCI, datastore exemption). Tests ship with the enforcement they pin, so splitting them out would break the tests-with-code rule for no review gain.
  • Why the test volume is irreducible: the coverage is a matrix — five lifecycle states plus an active negative control, across read and write paths, on four surfaces, with suspended writes fanned out per method. The gated 404 must be byte-identical to each surface's unknown-slug baseline (existence-hiding), so each suite captures and compares the baseline envelope per state. And the suites are DB-backed on purpose: the worst regression class is silent fail-open — a projection that omits a lifecycle column reads every namespace as serviceable — which unit fakes cannot catch. The suites are near-isomorphic tables, so review cost per line is low: read one suite closely, skim the variations.
  • internal/datastore/namespaces.go is not in the plan step's file list. The projection widening is a necessary enabler: the predicates read lifecycle columns off the resolved row, and the old explicit column lists omitted them.
  • Conformance: the protocol conformance flows exercise active namespaces, and the gate leaves active-namespace behavior untouched — each surface carries an active-baseline negative control pinning that. No conformance-suite change applies.
  • E2E catalog: no docs/testing/ scenario rows change. The gated states are reachable only by direct DB writes today (the service-condition endpoints arrive in a later MR of this plan); catalog coverage lands with those endpoints.
  • Pre-push passes: /validate-step (advisories fixed in-branch), /review-branch (APPROVE; its one warning is the LOC ceiling, justified above), /simplify (applied: predicate single-homing, shared lifecycle fixture in internal/testutil, suspension-specific npm message name).

Spec coverage

Spec: docs/specs/S33-gitlab-api.md (Serviceability gating). This MR owns AC-9 and AC-10 plus the FindByID half of AC-12's foundation; the remaining rows belong to the sibling GitLab API endpoint MRs of the same plan and are listed for completeness.

Acceptance criteria

# Criterion Tests
AC-1 Provisioning 201 creates the row with status: active Out of scope: provisioning endpoint MR
AC-2 Idempotent anchor replay returns 200, unaltered Out of scope: provisioning endpoint MR
AC-3 Replay with a disagreeing body returns 409 Out of scope: provisioning endpoint MR
AC-4 Brand-List Validator unreachable returns 503, no row Out of scope: provisioning endpoint MR
AC-5 Invalid/reserved/brand-rejected slug returns 422 Out of scope: provisioning endpoint MR (syntactic rules covered by internal/slug)
AC-6 Slug taken by another namespace returns 409 Out of scope: provisioning endpoint MR
AC-7 GET /namespaces/:uuid returns anchor, slug, derived status; unknown UUID 404 Out of scope: resolution endpoint MR
AC-8 Condition endpoints set/clear exactly their own column Out of scope: service-condition endpoints MR
AC-9 Suspended: client download succeeds, push rejected; blocked/disabled/deleted: both rejected TestGatingIntegration_NpmSuspendedIsReadOnly, TestGatingIntegration_MavenSuspendedIsReadOnly, TestGatingIntegration_OCISuspendedIsReadOnly, TestGatingIntegration_NpmReadPredicate, TestGatingIntegration_MavenReadPredicate, TestGatingIntegration_OCIReadPredicate, TestGatingIntegration_NpmActiveBaseline, TestGatingIntegration_MavenActiveBaseline, TestGatingIntegration_OCIActiveBaseline, TestResolver_ResolveRepository_ReadPredicateFailsAsNotFound, TestResolver_ResolveRepository_SuspendedResolvesReadOnly, TestResolver_ResolveRepository_ActiveResolvesUnsuspended, TestGateWrite_ClassifiesMutatingMethods
AC-10 Management-surface requests rejected by the same predicate table TestGatingIntegration_ManagementReadPredicate, TestGatingIntegration_ManagementSuspendedIsReadOnly, TestGatingIntegration_ManagementActiveBaseline
AC-11 Batch verifications 204/opaque 400 Out of scope: verifications endpoint MR
AC-12 Resolution and conditions work on a namespace in every state Endpoints out of scope (resolution and condition MRs); this MR pins the UUID-keyed exemption those endpoints stand on: TestNamespaceStore_FindByID_ReturnsEveryLifecycleState
AC-13 OpenAPI document defines every endpoint and validates in CI Out of scope: surface scaffolding MR

Error cases

# Condition Tests
E-1 All GitLab API endpoints: missing/invalid stub token 401 Out of scope: surface scaffolding MR (S08 stub)
E-2 All GitLab API endpoints: malformed JSON body 400 Out of scope: GitLab API endpoint MRs
E-3 Provision: slug fails validation 422 Out of scope: provisioning endpoint MR
E-4 Provision: slug taken 409 Out of scope: provisioning endpoint MR
E-5 Provision: replay body disagrees 409 Out of scope: provisioning endpoint MR
E-6 Provision: Brand-List Validator unreachable 503 Out of scope: provisioning endpoint MR
E-7 Resolution/conditions/verifications: unknown UUID 404 Out of scope: resolution, condition, and verifications MRs
E-8 Conditions: unknown <action> segment 404 Out of scope: service-condition endpoints MR
E-9 Verifications: unknown/foreign id, empty or oversized batch 400 Out of scope: verifications endpoint MR

The gating error surface itself (suspended write 403, gated read 404 identical to unknown-slug) is specified in the Serviceability gating section rather than the Error Cases table; it is covered by the AC-9/AC-10 tests above, including the existence-hiding baseline-equality assertions (assertMatchesBaseline, the OCI/maven unknown-slug baseline comparisons).

Security considerations

# Concern Tests
S-1 Internet-facing surface behind the bootstrap-token stub until ADR-020 Out of scope: surface scaffolding MR
S-2 Verifications response opacity (no repository-id oracle) Out of scope: verifications endpoint MR
S-3 No organization data leaves the registry Out of scope: resolution and verifications MRs
S-4 Slug input crosses a trust boundary into SQL and the Brand-List Validator Out of scope: schema CHECK constraints (merged) and provisioning MR; gating fixtures and queries here are parameterized

Resolved spec ambiguities

  • Serviceability gating -> the predicates are part of namespace resolution, so a suspended-namespace write is rejected 403 before request-body parsing or validation (pinned by the management malformed-body POST case and the maven garbage-body sidecar PUT case; an active namespace earns 400 for the same bodies).
  • Serviceability gating, status codes -> each surface renders the suspended-write 403 with its existing forbidden vocabulary: forbidden for the management envelope, the npm envelope, and the maven problem body; DENIED (the OCI Distribution Spec's 403 code) for the OCI envelope.
  • Serviceability gating, predicate table -> purged_at gates reads and writes exactly like blocked/disabled/deleted (AC-9 does not name it, but the read predicate requires purged_at IS NULL).
  • Plan "resolver unit tests per state" -> derived public surface: the read predicate is enforced at resolution (ErrNamespaceNotFound, repository finder never called), suspension rides on Resolution.NamespaceSuspended, and namespace.GateWrite classifies POST/PUT/PATCH/DELETE as writes and returns ErrNamespaceSuspended.

Database Review Evidence

Queries

Method Change Plan Timing
NamespaceStore.FindBySlug Projection widened by 5 lifecycle timestamp columns; WHERE slug = $1 LIMIT 1 unchanged Index Scan using unique_namespaces_slug, actual rows=1, shared hit=3 buffers 0.061 ms execution (600-row table)
NamespaceStore.FindByID Projection widened by 5 lifecycle timestamp columns; WHERE id = $1 LIMIT 1 unchanged Index Scan using pk_namespaces, actual rows=1, shared hit=3 buffers 0.022 ms execution (600-row table)
EXPLAIN (ANALYZE, BUFFERS) — FindBySlug
Limit  (cost=0.28..8.29 rows=1 width=142) (actual time=0.020..0.021 rows=1 loops=1)
  Buffers: shared hit=3
  ->  Index Scan using unique_namespaces_slug on namespaces  (cost=0.28..8.29 rows=1 width=142) (actual time=0.019..0.019 rows=1 loops=1)
        Index Cond: (slug = 'team-300'::text)
        Buffers: shared hit=3
Planning:
  Buffers: shared hit=145
Planning Time: 1.202 ms
Execution Time: 0.061 ms

Query:

SELECT namespaces.id, namespaces.created_at, namespaces.slug, namespaces.platform,
       namespaces.entity_type, namespaces.entity_id, namespaces.billing_entity_type,
       namespaces.billing_entity_id, namespaces.delivery_mode_override,
       namespaces.suspended_at, namespaces.disabled_at, namespaces.blocked_at,
       namespaces.deleted_at, namespaces.purged_at
FROM public.namespaces
WHERE namespaces.slug = 'team-300'
LIMIT 1;
EXPLAIN (ANALYZE, BUFFERS) — FindByID
Limit  (cost=0.28..8.29 rows=1 width=142) (actual time=0.013..0.013 rows=1 loops=1)
  Buffers: shared hit=3
  ->  Index Scan using pk_namespaces on namespaces  (cost=0.28..8.29 rows=1 width=142) (actual time=0.012..0.013 rows=1 loops=1)
        Index Cond: (id = '2966c8e9-cb54-4708-a1ff-04de5e2891d7'::uuid)
        Buffers: shared hit=3
Planning:
  Buffers: shared hit=8
Planning Time: 0.058 ms
Execution Time: 0.022 ms

Query:

SELECT namespaces.id, namespaces.created_at, namespaces.slug, namespaces.platform,
       namespaces.entity_type, namespaces.entity_id, namespaces.billing_entity_type,
       namespaces.billing_entity_id, namespaces.delivery_mode_override,
       namespaces.suspended_at, namespaces.disabled_at, namespaces.blocked_at,
       namespaces.deleted_at, namespaces.purged_at
FROM public.namespaces
WHERE namespaces.id = '2966c8e9-cb54-4708-a1ff-04de5e2891d7'::uuid
LIMIT 1;

Notes: the change is projection-only (five lifecycle timestamp columns added to the namespaceColumns() constructor); WHERE clauses, indexes, and plans are untouched, so both plans are identical to the pre-change queries apart from output width. No migrations in this MR (the columns are !1010 (merged)'s).

Closes #202 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading