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
202with 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 thedelete_allkey at all. Multi-select is therefore contract-supported. The merged#bulk_delete_artifactsexercises 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 answers404, 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 answer404whether or not the value is well formed. - One delete another artifact can refuse.
deleteContainerManifestanswers409when another manifest indexes the target, with the blocking digests inerror.details.parents. The client'sallowlisted_detailsdrops everydetailsshape 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 surfacesays Phase 1 defines no mutation "because this surface's own phasing puts every write in Phase 2: the delete endpoints serve as of ARv1.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 clienttable 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.
deleteNpmDistTagis hosted-only and answers404on 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 thekindread belongs in the view. Thedocs/plans/README.mdrow carried the same claim. - Research findings was stale on five monolith symbols.
npm_dist_tag.rb,manifest_type.rb, andmanifests_resolver.rball exist;#npm_dist_tagswas missing from the reads; the mutations list omittedartifacts/delete.rbandrepositories/delete_artifacts.rb. gitlab!252723 has merged, so it is no longer a merge-order item. - The manifests GraphQL connection is on
origin/masterend to end, so Steps 13, 14, and 15 inherit no gate from it.version_detail.vueis 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 speccolumn 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 readsneither 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.
upsertContainerTagserves, 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 surfaceno 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 ofv1.423.0itself. 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:205gives collection-level delete asDELETE .../tagswhere the contract servesPOST .../bulk_delete; the version and file prefixes sit under:package_idand:version_idrather 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.yamlis authoritative for the wire, and the amendment batch atS17-rest-management-api.md:1558already 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 gatingputs the render decision on an allow-or-deny verdict per action, embedded in the management API's domain responses, anddelete_artifactis 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 a403on 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.jsand none of them listed the file; the steps that evict are 12, 13, 14, 15, 17, and 18. The bulk mutations had no.mutation.graphqldocuments anywhere. The mutation steps promised request specs in Acceptance and in## Testing strategyand listed none, though the S05 precedent (gitlab!252723) carries both spec kinds. POSTretry. Settled onorigin/master, not a question for Steps 4 and 5:RETRY_OPTIONS[:methods]isIDEMPOTENT_METHODS - [:delete], andIDEMPOTENT_METHODSis%i[delete get head options put]in faraday-retry 2.4.0, the versionGemfile.lockpins.- The manifest
409. Named nowhere in the plan, and its digests are dropped byallowlisted_detailsbefore a caller sees them. - Path builders.
artifact_pathandbulk_delete_pathreturn 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
202reports 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