feat(managementapi): declare the packages bulk-delete operations

Stacked on feat(managementapi): declare the artifact delet... (!1445 - merged) • Hayley Swimelar • 19.3, merges after it.

Why

On these three routes the selector is the whole request, so the request schema is where a generated typed client fails its own first call. Publishing the contract first is what keeps that from happening: the oneOf makes every body shape the endpoint rejects unrepresentable, rather than leaving a client to discover the rejection at runtime.

This declares the three package-family bulk deletes (packages, versions, and files, each for maven and npm) with their selector schemas, their own 202, and the enqueue-unavailable 503. Nothing serves them yet, by design: the six declared arms are recorded in the route sweep's pending set, so .../packages/bulk_delete answers the mux's text/plain 405 through the sibling wildcard and the two nested paths answer 404 until the write-route scaffold registers them.

Step 4 of the merged S17 Phase 4 plan, whose Approach slices the phase into four contract MRs rather than one. The container-family bulk operations are a sibling MR on this same parent, and the two share no new component.

At 1,105 added lines this is over the 500 ceiling and irreducibly so within the step: the Bruno collection must ship in the same MR as the OpenAPI change (CLAUDE.md guardrail 15), which accounts for 174 lines, and 657 of the remainder is the contract suite, one three-route table plus its helpers. Nothing is deleted or rewritten.

What

  • delete_all is const: true, not a boolean. A single object with a delete_all boolean would marshal "delete_all": false on every subset request, and rejecting that body would fail every generated client on its first call. Under the oneOf, with both arms closed, a subset request cannot carry the key at all, and declining the whole collection is not spellable.
  • The 202 and 503 components belong to this route family. ArtifactDeleteAccepted is deliberately single-artifact: its own description says an operation accepting a batch promises less and describes its own 202. The container family declares its own equivalents in the sibling MR, so neither contract MR can reword the other's promise. Two near-identical 503 descriptions is the accepted cost.
  • One id-list schema, three subset arms. package_ids, version_ids, and file_ids are all canonical-lowercase UUID lists bounded 1 to 1,000, so the array is declared once with an anchored pattern. An entry answers 400, which is where it differs from an ID in a path segment: the segment declares no UUID syntax and answers 404, so a URL exposes no UUID-syntax oracle.
  • The pending entries expire loudly. An arm is exempt from the sweep only while it answers as unserved. A handler that starts serving one of these routes without dropping its entry reddens its own MR, and an entry naming an arm the document no longer declares fails the closing loop.
  • Two rules live at the decode layer, not in the schema. A body repeating a property name returns 400, and so does an entry spelling the all-zeros UUID. JSON Schema expresses neither: a repeated member name has collapsed by the time a schema sees the document, and a regular expression cannot carve one value out of a form it otherwise matches. The schema descriptions name both rules rather than encoding them, and no test asserts either at the schema. Enforcement for both arrives with the selector decode: chore(managementapi): add the bulk delete selec... (!1444 - merged) • Hayley Swimelar • 19.3. No window opens in between, because nothing serves these operations on this branch, and once the write-route scaffold registers them they answer 501 until the bulk route steps land, which depend on that decode.

Spec coverage

Scoped to the package-family bulk surface. The full table, with the error cases and security considerations, is in the body of the test(managementapi): pin the packages bulk-delete contract commit.

Spec rule Test
The document declares every Phase 4 endpoint with its failure responses, and expresses each bulk body as a oneOf over the subset and delete_all branches (the packages-bulk slice) TestContract_PackagesBulkDeletes_DeclareTheirSurface, TestContract_PackagesBulkDeleteSelectors_RoundTrip
No bulk request returns 409 or 422 on any route (contract half) same, closed per-operation status set plus an explicit absence for each
A subset body generated from the published schema round-trips, and the oneOf makes delete_all unrepresentable alongside a list TestContract_PackagesBulkDeleteSelectors_RoundTrip, TestContract_PackagesBulkDeleteAll_AdmitsOnlyTrue
A body carrying both properties, neither, or "delete_all": false is refused, as is a JSON type mismatch in either position TestContract_PackagesBulkDeleteSelectors_RoundTrip refused rows
Each selector takes the identifier its artifact's read resource is addressed by, and a non-canonical entry is refused TestContract_PackagesBulkDeleteIdList_DeclaresItsBounds, the alias and trailing-character rows
A batch of exactly the cap is accepted and one entry over is not, empty and null lists included TestContract_PackagesBulkDeleteIdList_DeclaresItsBounds (declared bounds and both boundary bodies)
A batch of repeated entries is applied as a set same (uniqueItems absent), plus the repeats row
Enqueue unavailability answers 503 service_unavailable TestContract_PackagesBulkDeletes_DeclareTheirSurface (the component and its Error body)
Each bulk route's path is its list route's path plus bulk_delete, and no bulk path accepts a second method same, the declared paths and the POST-only assertion
The declared arms reach no handler yet, and a pending entry stops skipping once its arm serves TestContractOperations_AreRegisteredAndServed, both directions plus its orphan loop

No docs/testing/ e2e scenario is added or affected. Nothing serves these operations, and the plan's Testing Strategy assigns the Maven and npm bulk journeys to the monolith slices that consume these endpoints.

Noted for follow-up, outside this step's declared file list

  • docs/dev/bruno.md goes stale in three places: the coverage list omits the package bulk deletes, the slug note counts sixteen requests where there are now nineteen, and the headless-run paragraph counts seven artifact deletes where there are now ten. Two sibling MRs edit the same prose, so whichever lands last folds all three in.
  • The parent branch's internal/managementapi/contract_test.go comment reading "no operation in this document declares a 503 yet" is falsified once this lands. It belongs to !1445 (merged) or a follow-up, not to a file this MR touches.
  • The plan's Status-table row for this step lands with the fan-out's single Status commit, because every sibling branch would otherwise edit the same rows.
  • Two plan details worth correcting when the plan is next touched. Step 4's Files list omits both the contract test file and the Bruno folder file, though its Tests line mandates the test. And its Depends-on names the Accepted component, which this step deliberately does not consume: the live dependency on the parent is the pending-operations mechanism plus the service_unavailable enum entry.
  • delete_all on the packages and files collections cannot be applied until the marker-column step adds the created_at the acceptance predicate reads to maven_packages, npm_packages, and maven_files. No window opens here, because no route serves these operations yet, but the bulk worker steps carry no dependency edge on that column step and should.
Context for LLM agents

Rationale

  • Reusing ArtifactDeleteAccepted for the bulk 202. Rejected: the single-artifact promise ("the artifact stops being readable") is false for a batch, where an entry resolving to nothing is a no-op and the caller observes the outcome by re-reading the collection. Narrowing a published promise later is client-visible.
  • One DeleteAllSelector and one ServiceUnavailable component shared by both bulk families. Rejected for this MR: the container-family contract lands in a parallel sibling MR on the same parent, and a component either could edit is a component either could reword out from under the other. Both families' insertion points are the same region of components, so a shared name would also collide as a duplicate key rather than as a conflict git flags. A consolidation once both have merged is cheap and additive.
  • Declaring maxItems: 1000 and an anchored entry pattern even though entry values and list length are validated separately from the selector shape. Kept: a generated client is not bound by them at runtime, so the server still refuses a non-canonical entry or an over-cap list, but the document is where a client author reads the bound, and the repo's own bounded-batch precedent (VerifyRepositoriesRequest.repository_ids in gitlab-v1.yaml) declares both.
  • Validating bodies by walking the document from the path and method to requestBody.content["application/json"].schema rather than by component name. A component-name lookup passes when the operation $refs the wrong schema, which is the drift most worth catching here.

Non-goals

  • Serving any of these operations. The write-route scaffold and the bulk enqueue, decode, and worker steps own that, and this MR deliberately leaves the surface unserved.
  • The container-family selectors (image_ids, tag_names, digests), the not_implemented code-enum entry, and the pending-set's 405 prose. All three belong to sibling MRs on this parent, and duplicating any of them here would collide with them.
  • Enforcing the repeated-property-name rejection. The strict decoder owns it, and the schema cannot express it.
  • The docs/dev/bruno.md coverage prose and the plan's Status table. Both are shared surfaces this fan-out edits once, recorded above.

Related to #313 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading