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

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

Why

The three container bulk-delete operations need declaring before their handlers land, the same contract-first reason the parent MR carries: consumers generate a typed client and mock against the schema first. This is Step 3 of the merged S17 Phase 4 plan, whose Approach slices the phase into four contract MRs rather than one.

Nothing serves these routes yet, by design. Their pending entries answer the mux's text/plain 405 on the images and tags paths, whose registered GET sibling already owns the path, and 404 on the manifests path, which no pattern matches. No docs/testing/ e2e scenario is added or affected for the same reason: nothing can exercise an unregistered route, and the plan's Testing Strategy assigns the management-delete journeys in docs/testing/e2e/docker.md and docs/testing/e2e/oci.md to the container image DELETE handler step.

These are also the first operations in the document to declare 503, which the parent enumerated ahead of them.

At 1,134 added LOC 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 is 196 lines, and 565 of the remainder is the test floor. Splitting further separates the three operations from the tests that pin them, which is the whole of what this step delivers.

What

  • The bulk 202 is its own component. ArtifactDeleteAccepted says on the record that it is written for a single named artifact, so ContainerBulkDeleteAccepted states the weaker promise: entries are applied asynchronously, an entry resolving to nothing is a no-op, an unappliable entry is skipped, and the response reports no per-entry outcome. What a re-read shows differs by selector, so the component says both: after a subset the named entries stop resolving, after delete_all the collection holds no entry older than the request, and neither re-read distinguishes a finished job from a running one.
  • delete_all is a branch, not a boolean. A single object with an optional flag would marshal "delete_all": false on every subset request, and rejecting that would fail every generated client on its first call. Under oneOf, with type: object and additionalProperties: false on both arms and const: true on the flag, a body carrying both properties, neither, or false matches no arm and is unrepresentable in a generated client.
  • delete_all is not the retry a subset body is, and the selector says so: each call fixes its own acceptance time, so resubmitting after a lost response also sweeps whatever was published in between. The durable fix belongs to the enqueue step; this is the contract stating the edge.
  • A repeated property name is the decoder's rule, not this schema's. JSON Schema validates an already-parsed document, by which point a repeat has collapsed to one value, so the oneOf cannot express it and each schema description says who does.
  • Entry validation is separate from selector shape, asymmetrically. A request validator does not enforce format at all, so any string passes image_ids and the service answers 400 on a non-canonical UUID. The digest and tag-name patterns do bind, which is the opposite choice from their path parameters, and deliberately: a malformed value in a path is missing-or-invalid and answers 404, while the same value in a body is submitted input and answers 400. The descriptions name that asymmetry, because a reader arriving from the parameter otherwise reads it as drift.
  • Two contract corrections ride along, and Step 4 references them rather than duplicating them. The Error schema claimed a declared path answers an undeclared method with 405, which is false in three directions: a method this document declares also answers 405 until its route is registered and 501 while the route stands as a placeholder, a path with no route at all answers 404, and on a bulk_delete path a method the sibling wildcard route serves resolves there instead. The reword makes the Allow header the authority on what a path serves. not_implemented was likewise absent from the code enum while a registered-but-unfilled route can emit it.

Spec coverage

Spec rule Test
The document defines every Phase 4 endpoint and expresses each bulk body as a oneOf over the subset and delete_all branches (the container-bulk slice, AC #30) TestContract_ContainerBulkDeletes_DeclareTheirResponses, TestContract_ContainerBulkSelectors_AcceptOnlyTheTwoBranches
No bulk request returns 409 or 422 on any route (AC #39, contract half) assertBulkContainerNoRefusalStatuses, plus the closed per-row status set
Every valid request answers 202 with an empty body (Outcome) the per-row 202-declares-no-content assertion, plus the ArtifactDeleteAccepted exclusion
Enqueue unavailability answers 503 service_unavailable (Error Cases) the same closed status set, now declaring the code the parent enumerated
A subset body generated from the schema round-trips, and delete_all is unrepresentable beside a list (AC #58) TestContract_ContainerBulkSelectors_AcceptOnlyTheTwoBranches
A body carrying both properties, neither, or "delete_all": false returns 400, as does a type mismatch (AC #59 (closed)) the same, one row per shape, plus a null list, a bare true, and a bare list
Each selector takes its Phase 3 identifier, and a rejected digest or tag name returns 400 (AC #47) the same, per-selector entry rows isolating each pattern clause
A batch of exactly the cap is legal and one entry over is not, duplicates included (AC #55 (closed)) the same, cap, cap-plus-one, and duplicates-only-overflow rows
Duplicates are applied as a set (AC #56 (closed)) the repeated-entry row, plus the assertion that no uniqueItems is declared
Each bulk path is its Phase 3 list route's path plus bulk_delete (AC #63) the path constants the response test resolves
No route accepts a method other than POST on a bulk_delete path (AC #65, contract half) TestContract_BulkDeletePaths_DeclareOnlyPost, which derives its path set from the document, so a later bulk family inherits the rule with no edit
A tag named delete_all is an ordinary tag (AC #66) the accepted-body row naming it

Every schema-bound row was proved by mutation rather than assumed: deleting type: object, deleting items, widening the digest pattern to sha(256|512), dropping the hyphen from the tag class, and shortening the 128 ceiling each fail a specific row now and each passed the whole suite and the redocly gate before.

Two things worth a reviewer's attention

  • The 503 declares no Retry-After. The document declares that header nowhere and the spec is silent, so setting the convention belongs with the handler step that first answers the status rather than with the contract.
  • ContainerTag.name allows 255 characters while tag_names entries cap at 128. 128 is correct: it is the OCI tag grammar, matching tagGrammar in internal/format/oci/manifest.go. The 255 is a pre-existing bound on a shared response schema, not something this MR should narrow.

Deviations from the step's declared file list

  • internal/managementapi/contract_test.go is not in the list, while the step's Tests: line requires a schema-level kin-openapi test, whose home is that file. The not_implemented enum-drift edit lands there too.
  • docs/dev/bruno.md is not in the list either. The three new requests falsify its request count, its coverage sentence, and its pathless-run inventory, so it had to change or go false.
  • The plan's Status table row is left empty. Three sibling branches stack on the same parent and would edit the same rows, so the rows are filled once after they land.

Naming coordinated with the packages-bulk branch

The shared components carry a Container prefix (ContainerBulkDeleteAccepted, ContainerBulkDeleteServiceUnavailable, ContainerBulkDeleteAllSelector) and the Go test identifiers a bulkContainer one, mirroring the Packages* and bulkPackages* names in feat(managementapi): declare the packages bulk-... (!1457 - merged) • Hayley Swimelar • 19.3 so the two families read symmetrically and neither squats shared space.

That symmetry costs something worth naming: the two families now carry byte-identical 202, 503, and delete-all-selector components, where one shared triple would do. The prefixing is the cheaper choice while both MRs are open, since renaming a published component is consumer-visible, but consolidating them once both have merged is the better end state. ServiceUnavailable in particular was the house style here, since every other error response in this document is named generically by status and shared across operations.

Context for LLM agents

Rationale

  • Reusing or generalizing ArtifactDeleteAccepted for the bulk 202. Rejected: its description commits it to one named artifact, and widening a published promise to cover no-ops and skips would make the single-delete guarantee unreadable from the component.
  • Stating the delete_all drain predicate as every batch's completion signal. Caught in review and fixed: a subset batch can never satisfy it, so a consumer implementing it literally would poll forever. The signal is now split by selector.
  • Expressing the repeated-property-name rule in the oneOf. Not possible rather than declined: the validator receives a parsed document. Stated in each description as the decoder's, so a reviewer checking the acceptance criterion against the schema does not read the gap as an omission.
  • Declaring a canonical-lowercase pattern on image_ids items. Declined: it would contradict ArtifactId, which every response shares, and the acceptance criterion already allocates entry validation to the service.
  • Keeping the 405 prose as inherited. Declined: it is false during the window this stack creates and the bulk_delete fallthrough falsifies it permanently.
  • enum: [true] instead of const: true. Either works at the pinned kin-openapi, verified by removing the const and watching two assertions fail. const is what OpenAPI 3.1 offers for a single fixed value.
  • Adding Retry-After to the 503. Declined here, deliberately, and named above for the handler step.

Non-goals

  • Serving any of these operations, decoding a selector, or enqueuing a job. The bulk selector decode, the enqueue seam, the workers, and the routes are later steps of the same plan.
  • The package-family bulk operations, which are feat(managementapi): declare the packages bulk-... (!1457 - merged) • Hayley Swimelar • 19.3.
  • Consolidating the two families' duplicated shared components. Named above as a follow-up once both have merged, not attempted while both are open.
  • The t-versus-tt finding on the parent MR's TestContract_ArtifactDeletes_DeclareTheirResponses. It lives in the parent's own function and belongs to that MR's triage. Every subtest closure added here passes tt into its helpers.

Related to #313 (closed)

Merge request reports

Loading
Loading