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_allisconst: true, not a boolean. A single object with adelete_allboolean would marshal"delete_all": falseon every subset request, and rejecting that body would fail every generated client on its first call. Under theoneOf, with both arms closed, a subset request cannot carry the key at all, and declining the whole collection is not spellable.- The
202and503components belong to this route family.ArtifactDeleteAcceptedis deliberately single-artifact: its own description says an operation accepting a batch promises less and describes its own202. The container family declares its own equivalents in the sibling MR, so neither contract MR can reword the other's promise. Two near-identical503descriptions is the accepted cost. - One id-list schema, three subset arms.
package_ids,version_ids, andfile_idsare all canonical-lowercase UUID lists bounded 1 to 1,000, so the array is declared once with an anchored pattern. An entry answers400, which is where it differs from an ID in a path segment: the segment declares no UUID syntax and answers404, 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 answer501until 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.mdgoes stale in three places: the coverage list omits the package bulk deletes, theslugnote 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.gocomment 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
Acceptedcomponent, which this step deliberately does not consume: the live dependency on the parent is the pending-operations mechanism plus theservice_unavailableenum entry. delete_allon the packages and files collections cannot be applied until the marker-column step adds thecreated_atthe acceptance predicate reads tomaven_packages,npm_packages, andmaven_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
ArtifactDeleteAcceptedfor the bulk202. 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
DeleteAllSelectorand oneServiceUnavailablecomponent 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 ofcomponents, 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: 1000and 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_idsingitlab-v1.yaml) declares both. - Validating bodies by walking the document from the path and method to
requestBody.content["application/json"].schemarather 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), thenot_implementedcode-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.mdcoverage prose and the plan's Status table. Both are shared surfaces this fan-out edits once, recorded above.
Related to #313 (closed)