docs(plans): the monolith artifact delete surface

Summary

The plan MR for the monolith artifact delete surface: all nine AR delete operations behind the version list and version detail views, from the client write methods through the GraphQL mutations to the Vue affordances. The plan ships in two slices: the first delivers the five single deletes end to end, and the four bulk operations reach their client methods only, with their mutations and affordances deferred. Across both slices eight of the nine reach an affordance; the container tag bulk delete stops at its mutation, for the reason Step 10 gives. No code ships here.

It spans monolith/S14 and monolith/S06 rather than sitting under one, because the client methods and mutations are shared between the two views and deleteVersion serves both. Plan granularity is already not one-to-one with specs in this directory (monolith/S02 has four plan files).

19 steps off four roots (Steps 1 through 4; Step 5 hangs off Step 4). Five client-write steps, where the two bulk steps share one private selector helper and one nested-bulk path builder. Nine mutations over five steps — four steps pair two mutations each and Step 8 adds one, because npm dist-tags have no sibling operation — all over the existing Mutations::ArtifactRegistry::Base, plus a shared bulk selector input type. Nine frontend steps rooted on one confirmation modal and the cache-eviction primitive a 202 with no body forces.

The two slices

The first slice is Steps 1 through 8, 11, 12, 13, and 15 through 18: the five single deletes end to end, plus the two bulk client methods, which add no public surface and are shaped by AR's committed contract. Deferred are Steps 9, 10, 14, and 19 — the bulk mutations, the shared selector input type, the multi-select bar on both tables, and the files tab's bulk delete.

The line falls above the client layer for two reasons. A GraphQL mutation is public schema and hard to unship. And neither S14 nor S06 mentions a bulk delete, a multi-select, or a selection affordance anywhere, so there is nothing for the promotion MR to promote until that UX exists — a bulk mutation shipped now fixes a schema name that MR then has to adopt or contradict.

Nothing about bulk is dropped. Every contract fact behind it stays in ## Approach and in Steps 4, 5, 9, 10, 14, and 19, because those facts do not move and re-deriving them later costs the same research twice. The deferred set is closed under the step DAG: no first-slice step depends on one of them, checked against every Depends on: field rather than eyeballed. Deferred steps keep their numbers and their Status rows, so a step MR's title denominator does not depend on which slice it belongs to.

Four contract facts that shaped the design

  • Every delete answers 202 with no body. Acceptance is not completion, so no payload carries a count and no component reads a result from the response. Rows leave a list by cache eviction rather than a refetch, and what the delete achieved is visible only on the next genuine list read — which can legitimately return an evicted row, since AR need not have applied the delete yet.
  • The bulk routes are a oneOf: either a subset id list (1 to 1000 entries, repeats legal and counted as sent) or a delete-all selector, as distinct types so a subset body cannot carry the delete_all key at all. Multi-select is therefore contract-supported. The merged #bulk_delete_artifacts exercises only the delete-all branch, so it is a partial precedent and the four new bulk methods need both.
  • Identifier validation differs by position. A malformed identifier in a body answers 400; the same one in a path segment answers 404, so a URL exposes no syntax oracle. That split drives the client error mapping, and it is why the container steps note their path segments answer 404 whether or not the value is well formed.
  • One delete another artifact can refuse. deleteContainerManifest answers 409 when another manifest indexes the target, with the blocking digests in error.details.parents. The client's allowlisted_details drops every details shape but the verification endpoint's, so Step 2 widens it, Step 7 maps the refusal, and Step 13 renders the digests.

Also carried through: kind neutrality. No method and no mutation takes or checks a repository kind, because AR dispatches on its own row, and AR serves remote arms for all nine.

The blocking dependency, recorded rather than assumed away

!2200 (merged) has merged, as 3a1b2761, and ## Dependencies says which steps its merge did not unblock. !2200 (merged) records that the AR gate cleared and promotes nothing: every delete stays Phase 2 of both specs, with no surface specified. Both specs now say so in their own words — S14's Phase 2 delete row and S06's Phase 2 write-actions row each read "Nothing on the AR side", name v1.423.0, and point at gitlab#627172, this plan's tracking issue, as where the remaining sequencing lives. So the prerequisite is a further spec MR promoting the deletes out of Phase 2, and ## Dependencies now splits what that MR must add by slice, so it can be scoped to what ships. That is also why the step Acceptance fields state behavior directly instead of citing spec criterion numbers the way sibling plans do: there are no criteria to cite yet.

The gate falls in two places rather than one, because the two spec facts differ in kind:

  • Steps 11 through 19 are forbidden. S14 carries this Phase 1 criterion, unchanged by 3a1b2761: "In Phase 1 the row menu renders no manage-tags, download, or delete item in any state, and not as a disabled item either." While it stands, the UI is out whatever AR serves.
  • Steps 6 through 10 are unspecified. S14's ## GraphQL surface says Phase 1 defines no mutation "because this surface's own phasing puts every write in Phase 2: the delete endpoints serve as of AR v1.423.0", and that "Each Phase 2 write action adds the mutation its endpoint allows". So the section anticipates these mutations without naming one, an argument, or an error mapping. Opening a step against that fixes a schema name the promotion MR then has to adopt or contradict.
  • Steps 1 through 5 are not held. Ruby methods behind a dark flag, shaped by AR's committed contract, which is the source of truth for the wire. S14's ## Ruby client table enumerates the four read methods its own Phase 1 slice adds and does not speak to the nine this plan adds.

## Open questions is non-empty for that one reason, which is worth a reviewer's attention given the template asks it to be empty before implementation begins. It closes for the first slice when a promotion MR covering the single deletes merges, and it names the two UX decisions this plan surfaced rather than settled: what a list does when a read after a delete returns an evicted row, which belongs to the first slice because every single delete evicts a row, and whether the manage-tags affordance removes more than one tag at a time, which belongs to the deferred slice.

What the fourth commit changes

e88b2e54 answers this MR's review: nineteen findings, six blocking. Every claim was re-verified against its source before the fix, and all of them held. The six blocking ones, in short:

  • Kind neutrality overreached. deleteNpmDistTag is hosted-only and answers 404 on a remote repository permanently, so "AR serves remote arms for all nine" was false. The pillar survives at the client and the mutation; the exception lands on Step 18's affordance, where the kind read belongs in the view. The docs/plans/README.md row carried the same claim.
  • Research findings was stale on five monolith symbols. npm_dist_tag.rb, manifest_type.rb, and manifests_resolver.rb all exist; #npm_dist_tags was missing from the reads; the mutations list omitted artifacts/delete.rb and repositories/delete_artifacts.rb. gitlab!252723 has merged, so it is no longer a merge-order item.
  • The manifests GraphQL connection is on origin/master end to end, so Steps 13, 14, and 15 inherit no gate from it. version_detail.vue is on master too, which review did not raise; what is absent is the tab structure, the files tab, and the npm dist-tag surface above the client.
  • The Owning spec column asserted ownership neither spec records on the four bulk rows. Neither spec mentions a bulk delete, a multi-select, or a selection bar anywhere. The column now reads neither spec, the correction sits in Research findings per AGENTS.md, and the promotion-MR ask says this is new scope rather than the promotion of a deferred row.
  • Step 15's reason was wrong in both halves. upsertContainerTag serves, so add-and-move is out of scope as unspecified rather than for want of a route. And the step's real gate went unnamed: no layer serves a manifest's tag set, which is an AR contract extension rather than a monolith merge.
  • S06 carries its own Phase 1 prohibition. Resting the gate on S14's alone left Steps 16 through 19 forbidden after a promotion MR inverting S14, so the ask is now both inversions, one per spec.

Contract facts corrected: delete_all: false is spellable and refused, not unspellable; parents declares uniqueItems: true; the 1000 is the ADR-004 per-manifest tag cap, operator-settable, bounding one request rather than a collection; and the bulk 413 and bulk 503 were mapped by no step. Plus: allowlisted_details needs its own element predicate, since the existing arm's UUIDv7 check rejects every digest; Step 11 takes no edge from Step 6; ADR-010 puts "cannot be undone" in the confirmation copy; and the contract declares fourteen delete operations, so the nine here are a named subset.

Two corrections to the review itself, recorded rather than applied silently. The precedent cited for the title-marker note does not exist in this repository, and monolith adoption of the marker is partial rather than absent — 2 of 40 merged Artifact Registry MRs sampled carry one. The substantive point stands, so the note says both.

What the third commit changes

Two things, and neither touches a contract fact.

Reconciling against the merged !2200 (merged). The plan was written for the world !2200 (merged) would leave; it has since merged, so its claims were re-checked against the merged text rather than the anticipated text. Two needed the difference:

  • The sentence the plan quoted from S14's ## GraphQL surface no longer exists. "the Phase 2 write actions add the mutations their endpoints allow once those routes serve" is now "Each Phase 2 write action adds the mutation its endpoint allows", and the section states the delete endpoints serve as of v1.423.0 itself. The plan quotes the merged wording, and the argument it used to make from outside is now the spec's own.
  • S14's Phase 1 criterion forbidding a delete affordance is unchanged by 3a1b2761, so the prohibition on the frontend steps stands. Recorded as checked rather than assumed, since it is the load-bearing gate.

Also: the delete-all paragraph stopped quoting v1.yaml's per-selector wording, which !2207 (merged) is open against. It now states the behavior, names the commit its description of the wording is true at, and gives the condition and the event !2207 (merged)'s merge is — so it reads correctly on either side of that merge.

The slice split, described in ### The two slices above. Step 17 carried both halves of the files tab, so it splits: 17 keeps the per-row delete and the new Step 19 takes the selection bar. That is the only new step; 9, 10, and 14 were already written and only change slice.

Governing ADRs

  • ADR-009 API design: the nine routes are the format-specific artifact APIs ADR-009 defines. The plan consumes them and adds no route, but "Conforms" was wrong: the served contract diverges from ADR-009 on three of these nine, and the earlier wording hid it. 009_api_design.md:205 gives collection-level delete as DELETE .../tags where the contract serves POST .../bulk_delete; the version and file prefixes sit under :package_id and :version_id rather than at the top level; and no management manifest delete is declared there at all. ADR-009 also labels the single deletes "soft or hard delete", which ADR-010's closed-beta phasing settles as permanent. api/openapi/v1.yaml is authoritative for the wire, and the amendment batch at S17-rest-management-api.md:1558 already covers every one of these, so this is a recorded divergence against a pending amendment, not a new escalation. Raised in review; the plan changes no contract either way.
  • ADR-014 frontend to Artifact Registry: the plan keeps the boundary, with AR reached over REST from the monolith's Ruby client and the browser reaching only the monolith's own GraphQL. No AR credential or AR-domain request originates in the browser.
  • ADR-021 authorization: every mutation carries authorize_granular_token skip_reason: :external_service_authorizes, because AR performs the per-operation check. The mutations conform. The affordances do not, and the plan now records that as a conflict rather than a pass. ADR-021's ### Permission checks for UI gating puts the render decision on an allow-or-deny verdict per action, embedded in the management API's domain responses, and delete_artifact is a Manager and Admin permission it maps every delete here to. No verdict is served today, and neither spec mentions one, so with the flag on a Viewer or Contributor would see every affordance and earn a 403 on click. "The plan adds no monolith-side gate that would duplicate it" was true but incomplete: there is nothing to duplicate. Closing it needs an AR verdict surface or a spec ruling, so it is an ask on the promotion MR. Raised in review.
  • No deviation, so no handbook ADR amendment is needed.

Testing

Documentation only; no code ships. The per-step Tests entries are what the implementing MRs will be held to.

Verified locally on the changed file: markdownlint-cli2 0 errors, vale --minAlertLevel error 0 errors, and lychee --offline --include-fragments 0 errors, so every intra-document anchor and every spec section link resolves. At the third commit that is 44 links, 16 unique, 38 checked and 6 excluded.

The second commit reconciles the plan against a review pass, and each claim it adds was checked against a source rather than reasoned about:

  • Step graph. Step 10 depends on Step 9 for the shared input type, which the DAG omitted and Step 9's Shared seams denied — the inconsistency Duo flagged. Two more of the same shape: Step 16 reuses Step 12's mutation document and eviction helper, and Step 17 imports Step 14's bulk action bar, neither recorded as an edge.
  • Files lists. Step 11 named four steps as adding eviction helpers to cache_update.js and none of them listed the file; the steps that evict are 12, 13, 14, 15, 17, and 18. The bulk mutations had no .mutation.graphql documents anywhere. The mutation steps promised request specs in Acceptance and in ## Testing strategy and listed none, though the S05 precedent (gitlab!252723) carries both spec kinds.
  • POST retry. Settled on origin/master, not a question for Steps 4 and 5: RETRY_OPTIONS[:methods] is IDEMPOTENT_METHODS - [:delete], and IDEMPOTENT_METHODS is %i[delete get head options put] in faraday-retry 2.4.0, the version Gemfile.lock pins.
  • The manifest 409. Named nowhere in the plan, and its digests are dropped by allowlisted_details before a caller sees them.
  • Path builders. artifact_path and bulk_delete_path return a top-level member and a top-level collection's bulk route. Six of the nine routes here sit one level deeper and no builder returns either shape.
  • Delete-all on a remote repository. The plan said remote npm; the container selector states the same pass without qualifying the format, and this plan covers both families.
  • Step 14's selection clearing. A 202 reports no per-entry outcome, so "clears only for the rows the response accepted" asked for a value the contract refuses to send.

Structure checked against the template and against monolith/2026-08-11-version-list: section order matches, and the plan carries 19 steps with 19 matching Status rows.

E2E impact: none, stated in the plan rather than left silent. artifact_registry_ui is dark for all 19 steps and no step enables it.

The third commit was verified the same way, and two of its claims were checked by script rather than by reading:

  • Every quotation of S14 was re-matched against origin/main's merged text with whitespace normalized, so the plan quotes what the spec says rather than what the plan anticipated it would say.
  • The Mermaid DAG's edge set and the union of every step's Depends on: field are equal, and no first-slice step has a deferred predecessor. The Status table's 19 rows match the 19 step headings by number and title.

The full pre-commit chain ran on the third commit with no skips, lychee included, and every hook passed. The lychee hook failure noted below did not recur, which supports it having been environmental.

Note from the earlier round: the lychee pre-commit hook refused to run in this checkout, claiming rev must be a versioned release tag when .pre-commit-config.yaml already pins lychee-v0.24.2. It looked environmental rather than related to this change, and it reproduced on !2200 (merged) too. I ran lychee manually with the same args the hook and docs:lint use, hence the result above, and committed with SKIP=lychee rather than editing repo config in an unrelated MR.

Related to gitlab-org/gitlab#627172

Edited by Fiona McCawley

Merge request reports

Loading
Loading