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-cli2 and vale both 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_delete over DELETE on the collection (what ADR-009 names). A DELETE body has no defined semantics under RFC 9110 and intermediaries may drop it. GitLab has no JSON-body DELETE convention; 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. POST for a batch is what S33's batch verifications already does in this service.
  • bulk_delete over deletions. deletions (modeled on S33's /verifications) reads like a listable sub-resource. bulk_delete matches the monolith's bulk_destroy action and the operation's own name in prose.
  • delete_all over all. Safe either way for a decoder, but a bare all next to a list of names misleads a human, tags named all will exist, and closed beta has no restore, so the failure is unrecoverable.
  • Rejected: a bare DELETE .../images meaning 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/v4 because 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 409 on a non-empty artifact. Phase 1's repository-delete 409 exists because that delete is hard and the FKs are NO 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-hidden 404 that detail returns.

Non-goals

  • Quarantine. ADR-009 defines it as a per-artifact PATCH on 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 annotations and 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 details envelope slot, needed for the two 409s, is still absent from main and lands with S33 work; the plan MR owns that ordering, not this spec. The 405 declaration is no longer part of it: !1208 (merged) has merged, so api/openapi/v1.yaml already carries the document-level contract and the method_not_allowed code.
Edited by Hayley Swimelar

Merge request reports

Loading
Loading