docs(specs): spec S17 phase 4 artifact writes
Why
S17 owns the whole /api/v1 surface and ships it in phases, each one patching this spec before any code. Phases 1 and 3 are specced; Phase 4 is the artifact write surface (delete, container tag upsert and untag, bulk delete) and had nothing written down, so there was no contract for a plan MR to sequence against.
Two decisions unblocked it. The API is designed for the soft-delete end state, and closed beta implements the mark and sweeps immediately, so S20 sets a recovery window without changing a single route or status code. Everything the phase does follows from holding those two at once.
The bulk-delete shape was the open question, and ADR-009 names the routes but specifies nothing about them: no body, no status code, no partial-failure rule, no limit.
Related to #313 (closed)
What
Only the parts a reviewer would not guess from the diff.
Delete answers 202, not 204. 202 promises acceptance without promising completion, the only promise both the closed-beta implementation and the S20 end state can keep. A 204 would assert completion and force a breaking change when S20 lands.
The request marks, jobs do the rest. A delete sets soft_deleted_at on the target and returns, a job marks the subtree, and a sweep removes the marked rows. The request stays bounded whatever the target holds, which ADR-004 forces: it caps neither manifests nor blobs per image, so a synchronous cascade over an image is unbounded. Marking the target hides everything a caller reaches through it, because every management read descends the parent chain.
The migration is wider than a column. soft_deleted_at goes on four container tables, which carry none today. container_blobs is in that set despite not being a delete target, because the pull path reads a blob by digest with no join to the manifest that reached it, which is also the one row a marked parent does not hide. All four container unique indexes become partial, the shape npm's already have, or a re-push collides with the marked row. The tag upsert needs more than that: its ON CONFLICT DO UPDATE never clears the marker, so left alone it answers 200 with a tag no read can see. Phase 4 also becomes the first writer of the Maven soft_deleted_at columns, work S10 had assigned to S20.
This reverses closed-beta scope recorded in two other specs. S12 and S22 both say closed beta ships hard delete only, and npm unpublish soft-deletes, but closed beta shi... (#470 - closed) • Pawel Rozlach exists to convert npm back to hard delete on the reasoning that a soft delete leaks storage. The sweep supplies the reclamation that reasoning found missing. Follow-ups records the amendments S12 and S22 now owe, that item as superseded, and the container column pulled forward from Align soft-delete state across the API and sche... (#472 - closed) • Pawel Rozlach.
Bulk delete takes a capped subset or delete_all, and every bulk route is a Phase 3 list route's path plus bulk_delete. That rule re-parents ADR-009's DELETE .../versions and DELETE .../files, which hang off the top-level prefixes Phase 3 uses only for detail-by-id. Those name no collection, so delete_all against them has no defined scope.
No artifact name is ever read as a selector. Tags named all, delete_all, or bulk_delete stay ordinary tags, because the subset arrives as values in a typed array while the selector is a separate boolean key.
A closed-beta delete frees metadata rows and zero storage, since blob reclamation is S28 and S28 is not implemented. The spec says so rather than implying reclamation happens.
Test plan
Docs only, no code. The gates that apply:
markdownlint-cli2andvaleboth clean on the two changed files.- Every internal anchor resolves to a real heading (checked by extracting
(#...)targets and diffing against generated heading slugs). - Route paths cross-checked against the Phase 3 route table, which is what caught the two unscoped ADR-009 bulk routes.
Verification is review: the Resolutions section carries one entry per decision with its reasoning, and Follow-ups records what this phase knowingly leaves open.
Context for LLM agents
Design rationale and rejected alternatives
POST .../bulk_deleteoverDELETEon the collection (what ADR-009 names). ADELETEbody has no defined semantics under RFC 9110 and intermediaries may drop it. GitLab has no JSON-bodyDELETEconvention; its own documented bulk tag delete is form-encoded. A query-string id list caps the batch far below 1,000, because that many UUIDs is roughly 37 KB of URL.POSTfor a batch is what S33's batch verifications already does in this service.bulk_deleteoverdeletions.deletions(modeled on S33's/verifications) reads like a listable sub-resource.bulk_deletematches the monolith'sbulk_destroyaction and the operation's own name in prose.delete_alloverall. Safe either way for a decoder, but a bareallnext to a list of names misleads a human, tags namedallwill exist, and closed beta has no restore, so the failure is unrecoverable.- Rejected: a bare
DELETE .../imagesmeaning delete-all. It would have honored ADR-009's route and shrunk the deviation, but it leaves a URL whose bare invocation empties a collection. A body is written deliberately; a URL is mistyped. - Rejected: an ids-only bulk delete. Cannot empty a large collection: 250 list pages plus 25 capped batches for a package at the ADR-004 ceiling, racing every concurrent publish.
- Rejected: a filter-only bulk delete (the monolith's REST tag cleanup shape). Its own frontend had to bypass
/api/v4because it could not delete a chosen set. Filters here are Phase 7 lifecycle-policy scope. - Rejected: async with a job handle. No
202-with-handle exists anywhere in this service, and inventing one means a handle schema, a status route, and a polling contract. ADR-003 anticipates async bulk operations without specifying a shape. Recorded as a follow-up. - Cascade over
409on a non-empty artifact. Phase 1's repository-delete409exists because that delete is hard and the FKs areNO ACTION. An artifact owns its subtree, and cascade is also what the soft-delete end state does. - Unknown bulk entries no-op while single delete
404s. Deliberate asymmetry: a batch expresses a desired end state and must be safe to resubmit, whereas a single delete addresses one resource and is owed the same missing-or-hidden404that detail returns.
Non-goals
- Quarantine. ADR-009 defines it as a per-artifact
PATCHon images and packages only, scoped to remote cached rows. Out of closed-beta scope, tracked in #482, and it does not touch the bulk surface. - Container manifest read by digest. The manifests list already serves every digest, so manifest detail with
annotationsand the raw payload joins the container-redesign fast-follow. Phase 4 does ship the manifest delete on that address without waiting for the read route, plus the bulk manifests route, and both are additions to ADR-009 rather than routes it already names. - Restore or undelete. Arrives with S20's recovery window. Phase 4 marks its targets, and closed beta sweeps them immediately, so there is no interval in which a restore could run.
- npm dist-tag bulk delete. The one Phase 3 list collection without a bulk route, because ADR-009 names bulk delete for images, image tags, packages, versions, and files only. Recorded as an additive follow-up with its route.
- ADR amendments. This MR does not edit
docs/adr/, which is synced from the handbook. Seven ADR-009 items, one ADR-010 item, and one ADR-021 item are recorded in Follow-ups instead. - Sequencing. The optional
detailsenvelope slot, needed for the two409s, is still absent frommainand lands with S33 work; the plan MR owns that ordering, not this spec. The405declaration is no longer part of it: !1208 (merged) has merged, soapi/openapi/v1.yamlalready carries the document-level contract and themethod_not_allowedcode.