feat(gitlabapi): add the six namespace service-condition endpoints

What

The six service-condition endpoints on the GitLab API surface, per S33 Service conditions:

POST /api/gitlab/v1/namespaces/{id}/block      POST .../unblock
POST /api/gitlab/v1/namespaces/{id}/disable    POST .../enable
POST /api/gitlab/v1/namespaces/{id}/suspend    POST .../unsuspend

Each action writes exactly one lifecycle column on namespaces through the new NamespaceStore.ApplyCondition/LiftCondition (a single UPDATE ... RETURNING; apply stamps through COALESCE(column, NOW()) so a replay keeps the original instant, lift assigns NULL unconditionally so a no-op lift stays a 200). Conditions apply in every lifecycle state, including soft-deleted and purged: the platform repairs namespaces through this surface, so there is no gating predicate in the WHERE clause, and the response reports the ADR-007 precedence winner rather than the condition just written.

Notable for reviewers

  • Six literal routes, not an {action} wildcard. The action table registers six literal patterns, so an unknown segment matches no route (fallback envelope 404), the irreversible delete/purge cannot be named at all, and every operation carries its own ServeMux-stamped r.Pattern: one metrics/log series per endpoint, with scanner junk accruing to the fallback series. TestNamespaceCondition_RouteRegistration_StampsOnePatternPerAction pins the pattern per action and TestNamespaceCondition_RouteTable_MatchesTheContract pins the table against the OpenAPI document and the three store constants (an added undocumented action fails both).
  • Tolerant-strict body policy. The operations declare no fields; no body or {} answers 200, while malformed JSON, an unknown field, a non-object (including a bare null, which encoding/json silently ignores into a struct target; the decode target is a pointer for exactly that reason), and trailing data answer 400. An over-cap body answers 413 on both framing paths.
  • Audit record. Every successful write logs one INFO record (namespace id, condition, direction). The row keeps no history and a lift NULLs the only evidence a block existed; this record is the trace until S21's audit stream lands.
  • Shared-helper hoists. pathNamespaceID and writeNamespaceStoreError moved out of the resolution handler to be shared with these routes; behavior-preserving, statuses and messages unchanged.

Spec coverage

Acceptance criteria

# Criterion Tests
AC-1 Valid slug and unused anchor returns 201 with the resource Provisioning endpoint, already landed: TestCreateNamespace_ValidRequest_Returns201
AC-2 Same-anchor replay returns 200, row unaltered Already landed: TestCreateNamespace_ExactReplay_Returns200, TestNamespaceStore_Create
AC-3 Replay with a disagreeing body returns 409 Already landed: TestCreateNamespace_StoreConflicts_Return409, TestNamespaceStore_Create_ReplayMismatch
AC-4 Brand-List Validator unreachable: 503, or replay Already landed: TestCreateNamespace_BrandListUnreachable_Returns503, TestCreateNamespace_ExactReplay_OutranksSlugRefusal
AC-5 Slug policy failure returns 422, or replay Already landed: TestCreateNamespace_InvalidSlug_Returns422, TestCreateNamespace_BrandListReserved_Returns422
AC-6 Slug taken by another namespace returns 409 Already landed: TestNamespaceStore_Create_SlugTaken
AC-7 Resolution returns anchor, slug, derived status; 404/400 Already landed: TestResolveNamespace_EveryLifecycleState_Returns200, TestResolveNamespace_UnknownUUID_Returns404, TestResolveNamespace_NonCanonicalID_Returns400
AC-8 Each of the six condition endpoints sets or clears exactly its own column; response and a subsequent read report the derived status with ADR-007 precedence TestNamespaceCondition_EachAction_Returns200WithUpdatedResource, TestNamespaceCondition_Replay_Returns200, TestNamespaceStore_ApplyCondition, TestNamespaceStore_ApplyCondition_ReplayKeepsTheTimestamp, TestNamespaceStore_LiftCondition, TestNamespaceStore_LiftCondition_NotInEffectIsANoOp, TestNamespaceStore_Conditions_ReturnEveryLifecycleColumn, TestNamespaceConditionIntegration
AC-9 Suspended namespace: client reads succeed, pushes rejected Serviceability gating, already landed in internal/namespace and the protocol suites. Not re-tested here.
AC-10 Management-surface requests rejected by the same predicate table Serviceability gating, already landed. Not re-tested here.
AC-11 Batch verifications 204/400 Not implemented yet: the verifications endpoint is a later MR.
AC-12 Resolution and condition endpoints work in every state, including soft-deleted and purged TestNamespaceCondition_EveryLifecycleState_Returns200, TestNamespaceStore_Conditions_ApplyInEveryLifecycleState, TestNamespaceConditionIntegration, and (resolution half, landed) TestResolveNamespace_EveryLifecycleState_Returns200
AC-13 The OpenAPI document defines every endpoint, schema, and response, and validates in CI TestConditions_ResponsesMatchOpenAPIContract, TestContract_ConditionPaths_DeclareTheCanonicalIDPattern, plus the landed TestContract_DocumentValidates and the lint:openapi redocly job

Error cases

Endpoint Condition Tests
All Missing or invalid service credential: 401, bodiless Owned by the S08 bootstrap-token middleware (internal/auth). Not tested here.
All Malformed JSON body: 400 bad_request TestNamespaceCondition_RequestBody (conditions declare no body; see the provisional reading below), and the landed TestCreateNamespace_MalformedBody_Returns400
Provision Slug policy failure 422; slug taken 409; replay mismatch 409; validator unreachable 503 Already landed (see AC-3 to AC-6).
Resolution, conditions, verifications :uuid is not a canonical UUID: 400 bad_request TestNamespaceCondition_MalformedID_Returns400, TestNamespaceCondition_NonCanonicalID_Returns400, TestContract_ConditionPaths_DeclareTheCanonicalIDPattern
Resolution, conditions, verifications Unknown namespace UUID: 404 not_found TestNamespaceCondition_UnknownNamespace_Returns404, TestNamespaceStore_Conditions_UnknownNamespace, TestNamespaceConditionIntegration
Conditions Unknown <action> segment: 404 not_found TestNamespaceCondition_UnknownAction_Returns404, TestNamespaceStore_Conditions_Guards (the store's own refusal of a condition value outside the three constants)
Verifications Unknown or foreign id, empty or oversized batch: 400 Not implemented yet: the verifications endpoint is a later MR.
Conditions Store fault: 500 with a static message, cause logged once TestNamespaceCondition_StoreFailure_Returns500, TestNamespaceCondition_CanceledRequest_WritesNothing

Security considerations

# Concern Tests
S-1 Internet-facing but never customer-facing; bootstrap-token stub is the only guard Owned by the S08 middleware and the central chain. Not tested here.
S-2 Verifications response deliberately opaque Not implemented yet: the verifications endpoint is a later MR.
S-3 No organization data leaves the registry TestNamespaceCondition_EachAction_Returns200WithUpdatedResource and TestNamespaceCondition_EveryLifecycleState_Returns200 compare the whole response body, so the billing anchor cannot appear; TestNamespaceConditionIntegration checks the same against a stored row
S-4 Untrusted input crosses into SQL TestNamespaceCondition_MalformedID_Returns400 and TestNamespaceCondition_NonCanonicalID_Returns400 assert the raw segment never reaches the store; TestNamespaceCondition_UnknownAction_Returns404 and TestNamespaceStore_Conditions_Guards keep the action off any column it does not name

Testing

No e2e scenario is affected: the management/GitLab API has no e2e catalog under docs/testing/, and this MR ships its own unit, contract, and integration coverage (store suites run against real PostgreSQL).

Not here

  • The canceled-request 500 path leaves the status unstamped, so wrappers report it as a 200 in logs, metrics, and spans; that is shared response.go behavior with the landed resolution/provisioning routes and gets its own fix MR.
  • Decode read-failures are classified as content 400s without a log; that classification lives in the shared decode path being hoisted to internal/transport in !1149 (merged), and moves with it.
  • This surface is authenticated but not authorized; #356 (closed) owns applying service authz to the GitLab API surface.
  • decodeEmptyRequestBody folds into transport.DecodeJSONBody once !1149 (merged) lands.

Closes #194 (closed)

Merge request reports

Loading
Loading