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
202is its own component.ArtifactDeleteAcceptedsays on the record that it is written for a single named artifact, soContainerBulkDeleteAcceptedstates 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, afterdelete_allthe collection holds no entry older than the request, and neither re-read distinguishes a finished job from a running one. delete_allis a branch, not a boolean. A single object with an optional flag would marshal"delete_all": falseon every subset request, and rejecting that would fail every generated client on its first call. UnderoneOf, withtype: objectandadditionalProperties: falseon both arms andconst: trueon the flag, a body carrying both properties, neither, orfalsematches no arm and is unrepresentable in a generated client.delete_allis 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
oneOfcannot express it and each schema description says who does. - Entry validation is separate from selector shape, asymmetrically. A request validator does not enforce
formatat all, so any string passesimage_idsand the service answers400on 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 answers404, while the same value in a body is submitted input and answers400. 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
Errorschema claimed a declared path answers an undeclared method with405, which is false in three directions: a method this document declares also answers405until its route is registered and501while the route stands as a placeholder, a path with no route at all answers404, and on abulk_deletepath a method the sibling wildcard route serves resolves there instead. The reword makes theAllowheader the authority on what a path serves.not_implementedwas 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
503declares noRetry-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.nameallows 255 characters whiletag_namesentries cap at 128. 128 is correct: it is the OCI tag grammar, matchingtagGrammarininternal/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.gois not in the list, while the step'sTests:line requires a schema-levelkin-openapitest, whose home is that file. Thenot_implementedenum-drift edit lands there too.docs/dev/bruno.mdis 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
ArtifactDeleteAcceptedfor the bulk202. 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_alldrain 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_idsitems. Declined: it would contradictArtifactId, which every response shares, and the acceptance criterion already allocates entry validation to the service. - Keeping the
405prose as inherited. Declined: it is false during the window this stack creates and thebulk_deletefallthrough falsifies it permanently. enum: [true]instead ofconst: true. Either works at the pinned kin-openapi, verified by removing theconstand watching two assertions fail.constis what OpenAPI 3.1 offers for a single fixed value.- Adding
Retry-Afterto the503. 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-ttfinding on the parent MR'sTestContract_ArtifactDeletes_DeclareTheirResponses. It lives in the parent's own function and belongs to that MR's triage. Every subtest closure added here passesttinto its helpers.
Related to #313 (closed)