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) with403via thenamespace.ErrNamespaceSuspendedsentinel, mapped through each surface's existing error vocabulary (RFC 9457forbiddenon Maven, S01forbiddenon npm and management,DENIEDon OCI). The gate fires before request-body parsing. - The UUID-keyed
FindByIDpath stays ungated (GitLab API exemption; the repair path must see every state), pinned by its own integration test. - The
FindByID/FindBySlugprojections now carry the five lifecycle columns through thenamespaceColumns()constructor — a projection that omits them decodes as NULL and silently reads every namespace as serviceable. - The suspended flag derives from
datastore.WriteServiceableat resolution, so the lifecycle predicates keep a single home innamespace_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
404must 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.gois 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 ininternal/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
403before request-body parsing or validation (pinned by the management malformed-body POST case and the maven garbage-body sidecar PUT case; an active namespace earns400for the same bodies). - Serviceability gating, status codes -> each surface renders the
suspended-write
403with its existing forbidden vocabulary:forbiddenfor 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_atgates reads and writes exactly like blocked/disabled/deleted (AC-9 does not name it, but the read predicate requirespurged_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 onResolution.NamespaceSuspended, andnamespace.GateWriteclassifies POST/PUT/PATCH/DELETE as writes and returnsErrNamespaceSuspended.
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 msQuery:
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 msQuery:
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)