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, atee/app/graphql/mutations/artifact_registry/repositories/delete_artifacts.rb, taking a repositorynameand deleting every artifact the repository holds (permanently, for ahostedrepository; evicting the cache, for aremoteone).ArtifactRegistryArtifactDelete, atee/app/graphql/mutations/artifact_registry/artifacts/delete.rb, takingnameplusidand 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:
- AR already fails closed on a kind it has no arm for (
writeContractViolation, a500), so the monolith's guard duplicated a check the service already performs. - AR is the authorization boundary: it gates on the
delete_artifactability per ADR-021. The monolith's own gate,read_artifact_registryon the organization, is a membership floor rather than the write check; per S05, AR is the authoritative authorizer untilmonolith/S07's ability pre-gating lands. kindis immutable in AR (declaredreadOnly, aPATCHcarrying it answers422) and isrequiredin AR's repository response, so there is no state a client-side guard would protect against that AR itself would not already reject.- Keeping eviction-specific names would have made the
hostedhalf of this same route unreachable, and would have needed a second pair of mutations later to reach it. - The rename was free now and would not have been later: nothing consumes these mutations yet, and both sit behind the disabled
artifact_registry_uiflag, 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
404into nil (nil_on_missingrescuesApiErrorand re-raises unlessstatus == 404), and a nil repository raisesraise_resource_not_available_error!. A401or403instead raisesAuthorizationError, which is not anApiErrorsubclass, so a denial never becomes the nil. - For a mutation, both the missing-repository nil and the
403-after-attempt route to the sameRESOURCE_ACCESS_ERRORmessage onGitlab::Graphql::Errors::ResourceNotAvailable. The only difference is arequest_idextension, attached when Artifact Registry supplied one on the403path. 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
hostedrepository is asserted to issue the identical request as aremoteone, 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
virtualrepository is asserted to surface AR's404as a mutation error, reported rather than pre-empted. For the single-artifact mutation,virtualrides the existing404case, 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.
- Step 1: !252683 (merged) (merged)
- Step 2: !252703 (closed) (closed, superseded by !252898 (merged))
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-exclusionConfirmed 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.rbConfirmed 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 --checkConfirmed: 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 PASSED7. 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 202A 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 202Single-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,
403after 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
QueryRecorderrequirement. - 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.mdis regenerated, so this needs a technical writer review. - Changelog: not required. The change sits entirely behind a disabled flag and both mutations are experiments.