S05/Step 3: GraphQL - Add Artifact Registry artifact-delete mutations

This targets master. Step 1, !252683 (merged), merged as b1c42ff1. Step 2, !252703 (closed), was closed as a duplicate of !252898 (merged), which implements the same change; step 2 added a lastDownloadedAt field to artifact element types, while this MR adds the delete mutations, and it never depended on step 2. The branch has been rebased onto master with step 2's commit dropped, so the diff here contains no step 2 content.

Renamed since the last push. ArtifactRegistryRepositoryClearCache is now ArtifactRegistryRepositoryArtifactsDelete (Mutations::ArtifactRegistry::Repositories::DeleteArtifacts), and ArtifactRegistryCachedArtifactDelete is now ArtifactRegistryArtifactDelete (Mutations::ArtifactRegistry::Artifacts::Delete). The guard that refused any repository whose kind was not remote is gone too, along with its translated error and the corresponding locale/gitlab.pot entry. The reasoning is below, in "Dispatch is AR's job, not this mutation's". Also renamed since the last push. The single-artifact mutation's input argument artifactId (String!) is now id (ID!). The old name collided with ArtifactRegistryMavenPackage, which already exposes artifactId (String!, the human-readable Maven coordinate, for example gitlab-shell) alongside id (ID!, the UUID Artifact Registry expects). A caller holding a Maven package in scope could therefore pass pkg.artifactId where pkg.id was needed, and it would typecheck, because the field and the old argument were both String!. That surfaces only as an "artifact not found" response, indistinguishable from the artifact genuinely being gone. The two single-artifact read resolvers already name this value id and type it ID, so the write side now matches them. Doing this while the mutation is an experiment behind a disabled flag with no consumer is free; after the flag lifts it would need mount_aliased_mutation and a deprecation cycle.

What this does

Step 3 of 3, and the last of the series. It adds the two mutations over the client write methods step 1 added:

  • ArtifactRegistryRepositoryArtifactsDelete, at ee/app/graphql/mutations/artifact_registry/repositories/delete_artifacts.rb, taking a repository name and deleting every artifact the repository holds (permanently, for a hosted repository; evicting the cache, for a remote one).
  • ArtifactRegistryArtifactDelete, at ee/app/graphql/mutations/artifact_registry/artifacts/delete.rb, taking name plus id and doing the same for a single artifact.

Both compose Mutations::ArtifactRegistry::Base unchanged and are mounted in ee/app/graphql/ee/types/mutation_type.rb as experiments.

Neither mutation accepts the collection as an argument. The route follows the repository's own format, because an argument would let a caller name a collection its repository does not have.

Dispatch is AR's job, not this mutation's

Neither mutation checks the repository's kind. AR reads kind from its own repositories row and dispatches on it: hosted permanently deletes the repository's published artifacts (a package takes its versions and their files, a Maven package's package-level maven-metadata.xml and its checksums, an npm package's dist-tags and cached metadata; an image takes its tags, manifests, and blob links); remote evicts the cached rows, leaving the upstream untouched, so a later pull re-caches; virtual answers 404, because it owns no artifact rows to act on.

The guard that used to sit here refused any repository whose kind was not remote, because the mutations were named for one arm of that dispatch. Renaming them to describe the route rather than one of its two arms left the guard with nothing to protect. Five reasons it is not coming back:

  1. AR already fails closed on a kind it has no arm for (writeContractViolation, a 500), so the monolith's guard duplicated a check the service already performs.
  2. AR is the authorization boundary: it gates on the delete_artifact ability per ADR-021. The monolith's own gate, read_artifact_registry on the organization, is a membership floor rather than the write check; per S05, AR is the authoritative authorizer until monolith/S07's ability pre-gating lands.
  3. kind is immutable in AR (declared readOnly, a PATCH carrying it answers 422) and is required in AR's repository response, so there is no state a client-side guard would protect against that AR itself would not already reject.
  4. Keeping eviction-specific names would have made the hosted half of this same route unreachable, and would have needed a second pair of mutations later to reach it.
  5. The rename was free now and would not have been later: nothing consumes these mutations yet, and both sit behind the disabled artifact_registry_ui flag, so there is no deprecation cycle to pay. Shipped under the eviction names, a rename would have cost one.

One residual case is worth naming rather than pre-empting: if monolith/S07's ability pre-gating later wants a different ability for evicting a re-fetchable cache than for permanently deleting published artifacts, one mutation cannot carry both and would be split then. While the flag is off, that split is nearly free, so it is not a reason to pre-split now. The same shape applies to granular token scopes: both mutations currently declare authorize_granular_token skip_reason: :external_service_authorizes.

AR's own spec, docs/specs/monolith/S05-repository-detail.md, still says these mutations "address a remote repository and have no hosted counterpart" and holds hosted artifact deletion out of scope. That is now wrong; an amendment making the mutations kind-neutral (AR dispatches) is being raised separately against that spec.

The payload is typed at the base repository type, not the details type

field :repository, ::Types::ArtifactRegistry::RepositoryType. The details type subclasses it and mounts the artifact connections, so typing the payload at the base makes "a caller cannot select an artifact connection inside a mutation payload" a property of the schema rather than a review rule. Selecting one would buy a second backend round trip per write.

Two backend calls per delete, not three

The repository read that supplies the format also becomes the payload, rather than the payload being read back after the write. This matters because a later step in this feature puts the single-artifact delete on every row of the artifact table, so a third call would not stay a one-off: it would multiply by however many rows the table holds. Reading back afterwards would also buy nothing: the service answers 202 for acceptance rather than completion, whether it permanently deleted or evicted a cache, so a read taken immediately after returns what the first read returned. The payload therefore carries counters read before the write, which is an honest value rather than an illusion of a post-write one, and the field description says so.

Authorization: one response for two different causes

  • The repository read turns only a 404 into nil (nil_on_missing rescues ApiError and re-raises unless status == 404), and a nil repository raises raise_resource_not_available_error!. A 401 or 403 instead raises AuthorizationError, which is not an ApiError subclass, so a denial never becomes the nil.
  • For a mutation, both the missing-repository nil and the 403-after-attempt route to the same RESOURCE_ACCESS_ERROR message on Gitlab::Graphql::Errors::ResourceNotAvailable. The only difference is a request_id extension, attached when Artifact Registry supplied one on the 403 path. Both request specs assert the identical top-level error for both cases, distinguishing them only by whether the delete request was issued at all, which is a server-side detail invisible to the caller.

Summarised: the mutation does not disclose to the caller which of the two happened. A viewer who cannot see the repository and a viewer who can see it but is refused the delete look the same from outside, which hides existence in the first case, at the cost of the 403 case carrying less information than it otherwise could.

Payload shape

Each payload carries the targeted repository plus the standard errors, and deliberately no count and no completion claim, because a 202 is acceptance rather than completion and the service guarantees one pass rather than an empty collection.

That no-count rule is asserted at the declaration rather than in a response body, in two small unit specs at ee/spec/graphql/mutations/artifact_registry/repositories/delete_artifacts_spec.rb and .../artifacts/delete_spec.rb. This matters because a request spec cannot guard a declared-but-unselected payload field, since GraphQL has no wildcard selection, so if a count field were added later every request spec would stay green. Those two specs are wholly declarative, three assertions each, and carry no behaviour.

Testing

All behaviour is in two request specs, one per mutation, at ee/spec/requests/api/graphql/mutations/artifact_registry/repositories/delete_artifacts_spec.rb and .../artifacts/delete_spec.rb, matching the layout of the six existing Artifact Registry mutation request specs.

The positive cases are a per-format table (maven, npm, docker, oci) asserting the correct route and collection segment, run against a remote repository. Two cases replace what the old guard used to cover:

  • A hosted repository is asserted to issue the identical request as a remote one, leaving the delete-or-evict choice to AR. The spec comments that this is asserted rather than assumed, because a kind check added later would silently make one of the two callers unreachable.
  • For the repository-wide mutation, a virtual repository is asserted to surface AR's 404 as a mutation error, reported rather than pre-empted. For the single-artifact mutation, virtual rides the existing 404 case, since it is the same status through the same mapping; that is noted in a comment rather than duplicated as a test.

Everything else is unchanged: the other-repository-name case, the 403-after-attempt case, the missing-repository existence-hiding case, the non-member case, and the flag-off case asserting no client is acquired. The 503 case is new in the single-artifact spec rather than carried over. The two routes contract different 503 meanings in Artifact Registry's OpenAPI document: the repository-wide bulk_delete route answers PackagesBulkDeleteServiceUnavailable (the job backend could not take the enqueue), while both single-artifact delete routes answer AuthorizationServiceUnavailable (authorization that could not be evaluated). Copying the sibling context would have asserted the wrong meaning, so this one is named and written for its own contract.

AR's REST contract is cited by operationId rather than line number, because line numbers in api/openapi/v1.yaml drift: bulkDeletePackages, deletePackage, deleteContainerImage, bulkDeleteContainerImages. Three of those four under- or mis-describe their remote arm in the contract prose (bulkDeletePackages and deletePackage name only npm as evicting on a remote repository, though remote maven is served end to end via resolveMavenBulkScope and MavenBulkWorker.ops; deleteContainerImage omits the remote arm entirely, though container_image_delete.go implements it as remoteContainerImageEvict). bulkDeleteContainerImages describes it correctly, which is what shows the other three are an oversight rather than policy. These specs assert remote maven works against the handler code, not the contract prose; a doc fix for that gap is being raised separately against AR.

The specs stay WebMock-stubbed, but not because AR has nothing to drive: its handler arms for these routes (remoteContainerImageEvict, mavenRemotePackageEvict, npmRemotePackageEvict, and the remote arms in resolveMavenBulkScope and MavenBulkWorker.ops) landed on AR main between 2026-08-20 and 2026-08-25, and have shipped in every AR release since v1.387.0 (latest v1.437.0). WebMock stays the right level for a GraphQL mutation spec regardless; what is unverified is which AR version runs in the environment this monolith integrates against.

29 examples, 0 failures, across the four spec files touched by this MR (the two request specs above plus the two declarative unit specs from "Payload shape"), and 1265 examples, 0 failures across the whole Artifact Registry rspec surface. Rubocop is clean on all 7 changed Ruby files. gitlab:graphql:check_docs and gitlab:graphql:check_introspection_sync both pass.

Regenerated artifacts

Two artifacts regenerate: doc/api/graphql/reference/_index.md and public/-/graphql/introspection_result.json.

app/assets/javascripts/graphql_shared/possible_types.json is unchanged because the extraction emits only unions and interfaces, and mutation payloads are object types. public/-/graphql/introspection_result_no_deprecated.json is unchanged because both new mutation fields are experiments and are stripped from that dump individually, after which their payload and input types drop out as unreachable.

Feature flag and changelog

Ships dark behind artifact_registry_ui, which is disabled. No changelog entry, because the change sits entirely behind a disabled flag.

References

Tracked by #626611 (closed), which covers all three steps and has a merge requests table with one row per step.

How to set up and validate locally

This ships dark behind the disabled artifact_registry_ui flag. Validation is the automated checks below, plus a console script that drives both mutations through the real schema with the Artifact Registry client stubbed. No live Artifact Registry is needed.

1. Rubocop, on the 7 changed Ruby files

git diff --name-only origin/master...HEAD -- '*.rb' | xargs bundle exec rubocop --force-exclusion

Confirmed output: 7 files inspected, no offenses detected.

2. RSpec

bundle exec rspec \
  ee/spec/graphql/mutations/artifact_registry/artifacts/delete_spec.rb \
  ee/spec/graphql/mutations/artifact_registry/repositories/delete_artifacts_spec.rb \
  ee/spec/requests/api/graphql/mutations/artifact_registry/artifacts/delete_spec.rb \
  ee/spec/requests/api/graphql/mutations/artifact_registry/repositories/delete_artifacts_spec.rb

Confirmed output: 29 examples, 0 failures. Across the Artifact Registry Ruby GraphQL and client surface, meaning ee/spec/graphql/{mutations,resolvers,types}/artifact_registry, both concerns/artifact_registry spec directories, ee/spec/lib/artifact_registry and ee/spec/requests/api/graphql/mutations/artifact_registry: 1184 examples, 0 failures.

3. Everything the graphql-verify CI job runs

Run each of these as its own rake invocation. Chaining check_docs after validate in a single invocation makes check_docs report a false "documentation is outdated". CI never sees that, because it calls each one as its own bundle exec rake.

bundle exec rake gitlab:graphql:validate
bundle exec rake gitlab:graphql:check_docs
bundle exec rake gitlab:graphql:check_introspection_sync
bundle exec rake gitlab:graphql:generate_all_introspection_schemas
bundle exec rake gitlab:graphql:schema:dump
node scripts/frontend/graphql_possible_types_extraction.js --check

Confirmed: validate reports OK on every query document, check_docs prints GraphQL documentation is up to date, check_introspection_sync prints All GraphQL introspection schemas are up to date, both schema dumps write, and the possible-types check exits 0. git status is clean after all six, so the committed generated artifacts match what the schema produces.

4. The graphql-eager-load-verify job

bundle exec rails runner 'Rails.application.eager_load!'

Confirmed: exit 0.

5. The mutations themselves

mutation($input: ArtifactRegistryRepositoryArtifactsDeleteInput!) {
  artifactRegistryRepositoryArtifactsDelete(input: $input) {
    errors
    repository { name format }
  }
}

with variables { "input": { "name": "maven-remote" } }, and:

mutation($input: ArtifactRegistryArtifactDeleteInput!) {
  artifactRegistryArtifactDelete(input: $input) {
    errors
    repository { name format }
  }
}

with variables { "input": { "name": "maven-remote", "id": "artifact-123" } }.

The payload repository has to be selected explicitly. The default selection stops before a nested object, so leaving it out makes every assertion on it read nil.

Both were also run against a live Artifact Registry, in section 7 below. The console script in section 6 covers the same path with the client stubbed, so it needs no running service.

6. Console script

Run with bundle exec rails runner <file>, or paste into rails console. It reuses the GDK's existing organization and users instead of creating new ones. Creating a claimable record needs the Cells topology service, which a default GDK does not run. It restores the feature flag and removes the namespace mapping row it added, so it leaves the GDK as it found it.

# Drives both artifact-delete mutations through GitlabSchema with the Artifact Registry
# client stubbed, so the schema, the flag gate, the membership gate, the format routing
# and the kind-neutrality claim are exercised without a live service.
#
# Reuses the GDK's existing organization and users rather than creating them: creating a
# claimable record (organization, user) needs the Cells topology service, which a default
# GDK does not run.

SLUG = 'resolved-handle'

organization = Organizations::Organization.first!
member = organization.organization_users.find_by(access_level: :owner)&.user ||
  organization.organization_users.first!.user
non_member = User.where.not(id: organization.organization_users.select(:user_id)).first

flag_was = Feature.enabled?(:artifact_registry_ui, organization)
Feature.enable(:artifact_registry_ui)

mapping = ArtifactRegistry::NamespaceMapping.find_by(organization: organization)
created_mapping = mapping.nil?
mapping ||= ArtifactRegistry::NamespaceMapping.create!(
  organization: organization, ar_namespace_id: Gitlab::Utils.uuid_v7
)
mapping.expire_registry_cache

fake_service_client = Class.new do
  def namespace(uuid:)
    ArtifactRegistry::Namespace.new('id' => uuid, 'slug' => SLUG, 'status' => 'active')
  end
end.new

fake_client = Class.new do
  attr_reader :calls
  attr_accessor :repo_kind, :repo_format

  def initialize
    @calls = []
    @repo_kind = 'remote'
    @repo_format = 'maven'
  end

  def repository(slug:, name:)
    @calls << [:repository, slug, name]
    return nil if name == 'gone'

    ArtifactRegistry::Repository.new(
      'id' => 'a1b2c3d4-0000-0000-0000-000000000000', 'name' => name,
      'format' => @repo_format, 'kind' => @repo_kind, 'visibility' => 'private',
      'downloads_count' => 0, 'size_bytes' => 0
    )
  end

  def bulk_delete_artifacts(slug:, repository_name:, format:)
    @calls << [:bulk_delete_artifacts, slug, repository_name, format]
    nil
  end

  def delete_artifact(slug:, repository_name:, format:, id:)
    @calls << [:delete_artifact, slug, repository_name, format, id]
    nil
  end
end.new

Organizations::Organization.define_method(:artifact_registry_service_client) { fake_service_client }
Organizations::Organization.define_method(:artifact_registry_client) { |current_user:| fake_client }

BULK = <<~GQL
  mutation($input: ArtifactRegistryRepositoryArtifactsDeleteInput!) {
    artifactRegistryRepositoryArtifactsDelete(input: $input) {
      errors
      repository { name format }
    }
  }
GQL

ONE = <<~GQL
  mutation($input: ArtifactRegistryArtifactDeleteInput!) {
    artifactRegistryArtifactDelete(input: $input) {
      errors
      repository { name format }
    }
  }
GQL

run = lambda do |query, input, user|
  GitlabSchema.execute(
    query, variables: { 'input' => input },
    context: { current_user: user, current_organization: organization }
  ).to_h
end

failures = []
check = lambda do |label, condition|
  failures << label unless condition
  puts "#{condition ? 'PASS' : 'FAIL'}: #{label}"
end

# 1. Repository-wide delete on a remote maven repository.
fake_client.calls.clear
result = run.call(BULK, { 'name' => 'maven-remote' }, member)
data = result.dig('data', 'artifactRegistryRepositoryArtifactsDelete')
check.call('bulk: no top-level errors', result['errors'].nil?)
check.call('bulk: payload errors empty', data && data['errors'] == [])
check.call('bulk: payload carries the targeted repository', data && data.dig('repository', 'name') == 'maven-remote')
check.call('bulk: routed with the resolved slug and the repository format',
  fake_client.calls.include?([:bulk_delete_artifacts, SLUG, 'maven-remote', 'maven']))
check.call('bulk: exactly two backend calls, not three', fake_client.calls.size == 2)

# 2. Single-artifact delete.
fake_client.calls.clear
result = run.call(ONE, { 'name' => 'maven-remote', 'id' => 'artifact-123' }, member)
data = result.dig('data', 'artifactRegistryArtifactDelete')
check.call('single: no top-level errors', result['errors'].nil?)
check.call('single: payload errors empty', data && data['errors'] == [])
check.call('single: passed the artifact id through untouched',
  fake_client.calls.include?([:delete_artifact, SLUG, 'maven-remote', 'maven', 'artifact-123']))
check.call('single: exactly two backend calls, not three', fake_client.calls.size == 2)

# 3. Kind neutrality: a hosted repository issues the identical request as a remote one.
fake_client.repo_kind = 'remote'
fake_client.calls.clear
run.call(BULK, { 'name' => 'same-name' }, member)
remote_calls = fake_client.calls.dup
fake_client.repo_kind = 'hosted'
fake_client.calls.clear
run.call(BULK, { 'name' => 'same-name' }, member)
check.call('kind neutrality: hosted issues the identical request as remote', fake_client.calls == remote_calls)
fake_client.repo_kind = 'remote'

# 4. The collection follows the repository's own format rather than an argument.
fake_client.repo_format = 'docker'
fake_client.calls.clear
run.call(BULK, { 'name' => 'images' }, member)
check.call('format routing: docker repository routes as docker',
  fake_client.calls.include?([:bulk_delete_artifacts, SLUG, 'images', 'docker']))
fake_client.repo_format = 'maven'

# 5. A non-member is refused and no delete is attempted.
if non_member
  fake_client.calls.clear
  result = run.call(BULK, { 'name' => 'maven-remote' }, non_member)
  check.call('non-member: top-level error raised', result['errors'].present?)
  check.call('non-member: no backend call issued', fake_client.calls.empty?)
else
  puts 'SKIP: non-member checks, every user in this GDK belongs to the organization'
end

# 6. A repository the viewer may not see hides existence and attempts no delete.
fake_client.calls.clear
result = run.call(BULK, { 'name' => 'gone' }, member)
check.call('hidden repository: top-level error rather than a mutation error', result['errors'].present?)
check.call('hidden repository: read attempted, delete not',
  fake_client.calls == [[:repository, SLUG, 'gone']])

# 7. Flag off acquires no client at all.
Feature.disable(:artifact_registry_ui)
fake_client.calls.clear
result = run.call(BULK, { 'name' => 'maven-remote' }, member)
check.call('flag off: top-level error raised', result['errors'].present?)
check.call('flag off: no backend call issued', fake_client.calls.empty?)

# Leave the GDK as it was found.
flag_was ? Feature.enable(:artifact_registry_ui) : Feature.disable(:artifact_registry_ui)
mapping.destroy! if created_mapping

puts
puts failures.empty? ? 'ALL CHECKS PASSED' : "FAILED: #{failures.join(', ')}"

Confirmed output:

PASS: bulk: no top-level errors
PASS: bulk: payload errors empty
PASS: bulk: payload carries the targeted repository
PASS: bulk: routed with the resolved slug and the repository format
PASS: bulk: exactly two backend calls, not three
PASS: single: no top-level errors
PASS: single: payload errors empty
PASS: single: passed the artifact id through untouched
PASS: single: exactly two backend calls, not three
PASS: kind neutrality: hosted issues the identical request as remote
PASS: format routing: docker repository routes as docker
PASS: non-member: top-level error raised
PASS: non-member: no backend call issued
PASS: hidden repository: top-level error rather than a mutation error
PASS: hidden repository: read attempted, delete not
PASS: flag off: top-level error raised
PASS: flag off: no backend call issued

ALL CHECKS PASSED

7. Verified against a live Artifact Registry

Both mutations were run end to end against a real Artifact Registry service, with no stubs anywhere. The setup: GDK on gdk.test:3000, Artifact Registry built from main and listening on localhost:8080, its own PostgreSQL and Redis in Docker, and a shared service token configured on both sides so the /api/gitlab/v1 surface authenticates. Artifact Registry verifies the per-user JWT the monolith mints by fetching the GDK's JWKS at http://gdk.test:3000/oauth/discovery/keys.

Repository-wide delete against a hosted maven repository:

{ "data": { "artifactRegistryRepositoryArtifactsDelete": {
  "errors": [],
  "repository": { "name": "e2e-maven-26546e", "format": "MAVEN", "kind": "HOSTED" } } } }

The same mutation against a remote maven repository:

{ "data": { "artifactRegistryRepositoryArtifactsDelete": {
  "errors": [],
  "repository": { "name": "e2e-remote-07eafe", "format": "MAVEN", "kind": "REMOTE" } } } }

Artifact Registry's own access log for those two calls is the evidence for the kind-neutrality claim. The route is identical and only the repository name differs, so the monolith really does leave the delete-or-evict choice to the service:

POST /api/v1/gdk-local-test/repositories/e2e-maven-26546e/maven/packages/bulk_delete   202
POST /api/v1/gdk-local-test/repositories/e2e-remote-07eafe/maven/packages/bulk_delete  202

A hosted npm repository takes the same route, with the collection segment set by its own format. This is the "no collection argument" decision working as intended:

POST /api/v1/gdk-local-test/repositories/e2e-npm-018a44/npm/packages/bulk_delete       202

Single-artifact delete for an artifact that does not exist. Artifact Registry answers 404, and it surfaces as a mutation error rather than a raise, on both a hosted and a remote repository:

DELETE /api/v1/gdk-local-test/repositories/e2e-maven-26546e/maven/packages/d101f913-…   404
DELETE /api/v1/gdk-local-test/repositories/e2e-remote-07eafe/maven/packages/614c458f-…  404
{ "data": { "artifactRegistryArtifactDelete": {
  "errors": ["artifact not found"], "repository": null } } }

A repository that does not exist takes the other path. It raises resource-not-available at the top level with a null payload, hiding existence rather than reporting a mutation error:

{ "errors": [{ "message": "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
               "path": ["artifactRegistryRepositoryArtifactsDelete"] }],
  "data": { "artifactRegistryRepositoryArtifactsDelete": null } }

Two notes on what this run could not cover. A virtual repository could not be created, because Artifact Registry still answers kind must be hosted or remote; virtual repositories are not yet supported, so the virtual 404 case remains covered by the request spec only. Neither repository held any artifacts, so the 202 here is acceptance of an empty collection; the route, the format segment, the payload and the error mapping are what this exercises.

The single-artifact runs recorded above were made before the artifactId to id rename, so the GraphQL input actually sent was artifactId, not id. This does not affect the evidence: the argument name is only the GraphQL input key, and the REST path Artifact Registry receives is built from the argument's value, so the route, status codes and response bodies recorded above are unchanged by the rename. The runs have not been redone against the renamed argument.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Against the GraphQL API merge request checklist:

  • Breaking changes: none. Both mutations are new, and both are marked as experiments.
  • Authorization: covered in the request specs (non-member refused, missing repository hidden, 403 after the read boundary surfaced as a mutation error).
  • Performance: two backend calls per delete, asserted in the specs and in the script above. No new list field, so no N+1 surface and no QueryRecorder requirement.
  • Multiversion compatibility: nothing consumes these mutations yet, and the flag is disabled, so backend and frontend are not shipping together.
  • Technical writing review: doc/api/graphql/reference/_index.md is regenerated, so this needs a technical writer review.
  • Changelog: not required. The change sits entirely behind a disabled flag and both mutations are experiments.
Edited by Fiona McCawley

Merge request reports

Loading
Loading