feat(gitlabapi): scaffold the GitLab API surface

What

Step 4 of the S33 Phase 1 plan: the mounted-but-minimal /api/gitlab/v1 surface (ADR-009) that the namespace endpoints of the later steps register on.

  • internal/gitlabapi: NewHandler(Deps) with a private ServeMux and an envelope-404 fallback — an unmatched path under the subtree answers the S01 envelope with the correlation ID echoed, never the stdlib text/plain miss (the consumers are generated platform clients that decode the envelope on every error path). The Namespace resource DTO carries the seven wire keys; status comes from the shared ADR-007 derivation (never re-implemented), created_at is RFC 3339 UTC, and the billing anchor stays server-side under the additive-only policy.
  • Auth: a seventh dispatch-table row routes /api/gitlab/v1/... through the S08 bootstrap-token interim (Bearer, realm="artifact-registry", empty 401 body — byte-identical posture to the management API row). /api/gitlab/v2/... and other near-misses stay on the unknown-prefix 404 fall-through, preserving the additive versioning story. The row's second literal segment generalized the matcher's anchor to a positional anchors slice.
  • transport.CodeServiceUnavailable (503) extends the S01 code set for the fail-closed paths that land with the provisioning endpoint.
  • api/openapi/gitlab-v1.yaml: OpenAPI 3.1 skeleton (info, Error, Namespace), registered as gitlab@v1 in redocly.yaml. Response schemas are deliberately open (no additionalProperties: false) and enum growth is documented as tolerated — the flip side of the surface's additive-only compatibility promise. lint:openapi now lints every registered document; the pages job publishes the rendered reference under /api/gitlab/.
  • internal/gitlabapi/contract_test.go: the contract rig — document loads and validates (kin-openapi's 3.0-era semantics; the redocly CI lint is the authoritative 3.1 gate), schema required-set and emitted keys pinned in both directions against the serializer, the fallback 404 body validated against the Error schema, and the code enum pinned to the transport constants.
  • docs/dev/gitlab-api.md: the surface's dev page — conventions, the backward-compatibility policy, and the endpoint registration checklist.

Stacking

Opened stacked on Step 3's branch; Steps 1–3 (!1010 (merged), !1012 (merged) via !1010 (merged), !1022 (merged)) have since merged, so this MR now targets main directly and the branch was rebased onto main (squash merges rewrite history, so the step's commits were replayed with rebase --onto). Plan-wise, Step 4 depends only on Step 1.

Review notes

  • Size: ~1,350 LOC total: 919 tests, 138 OpenAPI, 88 docs, 19 CI/tooling, and only ~185 of production Go (a handler with one fallback route, a DTO, a dispatch row, and a constant). The bulk is the contract/test rig the sibling endpoint MRs build on.
  • Why one MR, not two: the only clean seam is the auth/transport groundwork (the dispatch row plus service_unavailable, ~355 changed lines) versus the rest. That split still leaves a ~990-line second MR, because the OpenAPI document, the contract tests that pin it, the handler they exercise, and the dev page that describes them have to travel together; cutting between them would separate the contract from its tests. With only ~185 lines of production Go in the whole diff, peeling the groundwork off barely reduces review effort while deepening the step-4 → step-5 stack, so the step ships as one MR. The seam stands if reviewers prefer the split.
  • Files beyond the plan's Step 4 list: internal/auth/dispatch.go (+tests) — required by the step's "stub auth enforced" acceptance, which no listed file could satisfy; cmd/artifact-registry/wire.go — the one-line boot call site for the listed wire_gitlab.go; the lint:openapi script change — the job linted v1.yaml by explicit path, so registering gitlab@v1 alone would not have put the new document under CI validation.
  • Spec reading (401 shape): the spec's Error Cases table lists envelope code unauthorized for a missing/invalid service credential, but the surface ships the S08 interim — bodiless 401 with the Bearer challenge, the same shape the management API serves — per the spec's own "the same interim used by the management API" sentence. The OpenAPI Error description records the exception. Follow-up: amend the spec's Error Cases row to the interim shape.
  • Note for the endpoint MRs (Steps 5–8): the datastore status derivation reads five lifecycle columns, and a projection that omits one silently reads as active. Each endpoint MR should carry an integration test reading a non-active row through its real query.
  • Conformance: no Maven/npm/OCI protocol behavior changes; no conformance-suite impact.
  • E2E catalog: no docs/testing/ scenario rows; the surface exposes no endpoints yet.
  • Pre-push passes: /validate-step (findings fixed in-branch), /review-branch (REQUEST CHANGES; every finding addressed in-branch or recorded above), the local PR-review toolkit, and /simplify (applied: the anchors generalization; declined: looping pages over registered APIs — revisit when a third document registers).
  • A second full PR-review-toolkit round ran post-open: it produced the dev-page fallback correction, the stale-comment refreshes, and four test-hardening changes (assembled-app mount probe, emitted-keys⊆schema pin, dispatch-table row-shape invariants, TestMatch row-identity assertion), followed by a comment-conciseness pass.

Spec coverage

Spec: docs/specs/S33-gitlab-api.md (surface conventions, OpenAPI contract, error envelope). This MR owns the scaffolding slice of AC-13 plus the surface-wide 401/404 shapes and the service_unavailable transport code; endpoint rows belong to the sibling 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; the service_unavailable code it renders with is pinned here by TestWriteError_ServiceUnavailable
AC-5 Invalid/reserved/brand-rejected slug returns 422 Out of scope: provisioning endpoint MR
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 Endpoint behavior out of scope: resolution endpoint MR. The shared resource it serializes is pinned here: TestNamespaceFromModel_MapsRowFields, TestNamespace_WireShape, TestNamespaceFromModel_CreatedAtNormalizedToUTC
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 Out of scope: serviceability-gating MR (!1022 (merged), merged)
AC-10 Management-surface requests rejected by the same predicate table Out of scope: serviceability-gating MR (!1022 (merged), merged)
AC-11 Verifications 204/opaque 400 Out of scope: batch verifications endpoint MR
AC-12 Resolution and conditions work in every state Endpoint behavior out of scope: resolution/conditions MRs. The resource's per-state status derivation is pinned here: TestNamespaceFromModel_DerivedStatus
AC-13 OpenAPI document defines every endpoint/schema/response and validates in CI TestContract_DocumentValidates, TestContract_NamespaceSchema_MatchesResource, TestContract_Unknown404_MatchesErrorSchema (document skeleton: info, Error, Namespace; operation entries extend the rig as each endpoint MR lands)

Error cases

# Condition Tests
E-1 All: missing or invalid service credential (stub token) 401 unauthorized TestMiddleware_GitlabAPIRow_Positive, TestMiddleware_GitlabAPIRow_Negative
E-2 All: malformed JSON body 400 bad_request Out of scope: endpoint MRs (no scaffolding route parses a body)
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 service_unavailable Out of scope: provisioning endpoint MR; the code constant and 503 envelope round-trip: TestWriteError_ServiceUnavailable
E-7 Resolution/conditions/verifications: unknown UUID 404 not_found Out of scope: endpoint MRs
E-8 Conditions: unknown <action> segment 404 not_found Endpoint route table out of scope: conditions MR. The surface-wide envelope-404 fallback it rides is pinned here: TestNewHandler_UnknownPath_Returns404WithEnvelope, TestContract_Unknown404_MatchesErrorSchema, TestWireGitlabAPI_MountsEnvelopeHandler, TestWireGitlabAPI_UnmountedPathIsBareMux404
E-9 Verifications: unknown/foreign id, empty or oversized batch 400 Out of scope: batch verifications endpoint MR
E-10 service_unavailable (503) added to the shared internal/transport code set TestWriteError_ServiceUnavailable

Security considerations

# Concern Tests
S-1 Internet-facing surface guarded only by the bootstrap-token stub until the service credential lands TestMiddleware_GitlabAPIRow_Negative (fail-closed 401 on missing/empty/wrong-scheme/mismatched credentials), TestMiddleware_GitlabAPIRow_Positive, TestMiddleware_GitlabAPIRow_UnknownVersionFallsThrough
S-2 Verifications response deliberately opaque Out of scope: batch verifications endpoint MR
S-3 No organization data leaves the registry; responses return what the caller already persisted TestNamespace_WireShape (exactly the seven caller-known keys; billing anchor excluded); endpoint MRs extend per response
S-4 Slug input crosses a trust boundary into SQL and the Brand-List Validator Out of scope: slug-validation and provisioning MRs

Resolved spec ambiguities

  • Error Cases 401 row -> the S08/management-API interim: the row lists envelope code unauthorized, but surface conventions defer auth to "the same interim used by the management API", whose non-OCI 401s emit Bearer realm="artifact-registry" and an empty body; the tests pin that shape.
  • Namespace resource field set -> seven wire keys: the spec/plan name "anchor tuple, slug, derived status"; AC-1 adds the namespace UUID and AC-2 adds created_at, so the resource is id, slug, platform, entity_type, entity_id, status, created_at. The billing anchor is excluded: under the additive-only v1 policy an omitted field can be added later, an emitted one is permanent.
  • AC-13 at this step -> document skeleton only (info, Error, Namespace schemas); operation entries validate as each endpoint MR registers them, per the plan's testing strategy.

Related to #181 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading