diff --git a/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java b/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java
deleted file mode 100644
index 0ddc5f2428d..00000000000
--- a/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java
+++ /dev/null
@@ -1,246 +0,0 @@
-// Copyright 2025 The Nomulus Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package google.registry.batch;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.net.MediaType.PLAIN_TEXT_UTF_8;
-import static google.registry.flows.FlowUtils.marshalWithLenientRetry;
-import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
-import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
-import static google.registry.util.DateTimeUtils.END_OF_TIME;
-import static google.registry.util.ResourceUtils.readResourceUtf8;
-import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
-import static jakarta.servlet.http.HttpServletResponse.SC_NO_CONTENT;
-import static jakarta.servlet.http.HttpServletResponse.SC_OK;
-import static java.nio.charset.StandardCharsets.US_ASCII;
-
-import com.google.common.base.Ascii;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.RateLimiter;
-import google.registry.config.RegistryConfig.Config;
-import google.registry.flows.EppController;
-import google.registry.flows.EppRequestSource;
-import google.registry.flows.PasswordOnlyTransportCredentials;
-import google.registry.flows.StatelessRequestSessionMetadata;
-import google.registry.model.common.FeatureFlag;
-import google.registry.model.contact.Contact;
-import google.registry.model.domain.DesignatedContact;
-import google.registry.model.domain.Domain;
-import google.registry.model.eppcommon.ProtocolDefinition;
-import google.registry.model.eppoutput.EppOutput;
-import google.registry.persistence.VKey;
-import google.registry.request.Action;
-import google.registry.request.Action.GaeService;
-import google.registry.request.Response;
-import google.registry.request.auth.Auth;
-import google.registry.request.lock.LockHandler;
-import jakarta.inject.Inject;
-import jakarta.inject.Named;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.logging.Level;
-import javax.annotation.Nullable;
-import org.joda.time.Duration;
-
-/**
- * An action that removes all contacts from all active (non-deleted) domains.
- *
- *
This implements part 1 of phase 3 of the Minimum Dataset migration, wherein we remove all uses
- * of contact objects in preparation for later removing all contact data from the system.
- *
- *
This runs as a singly threaded, resumable action that loads batches of domains still
- * containing contacts, and runs a superuser domain update on each one to remove the contacts,
- * leaving behind a record recording that update.
- */
-@Action(
- service = GaeService.BACKEND,
- path = RemoveAllDomainContactsAction.PATH,
- method = Action.Method.POST,
- auth = Auth.AUTH_ADMIN)
-public class RemoveAllDomainContactsAction implements Runnable {
-
- public static final String PATH = "/_dr/task/removeAllDomainContacts";
- private static final String LOCK_NAME = "Remove all domain contacts";
- private static final String CONTACT_FMT = "%s";
-
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private final EppController eppController;
- private final String registryAdminClientId;
- private final LockHandler lockHandler;
- private final RateLimiter rateLimiter;
- private final Response response;
- private final String updateDomainXml;
- private int successes = 0;
- private int failures = 0;
-
- private static final int BATCH_SIZE = 10000;
-
- @Inject
- RemoveAllDomainContactsAction(
- EppController eppController,
- @Config("registryAdminClientId") String registryAdminClientId,
- LockHandler lockHandler,
- @Named("removeAllDomainContacts") RateLimiter rateLimiter,
- Response response) {
- this.eppController = eppController;
- this.registryAdminClientId = registryAdminClientId;
- this.lockHandler = lockHandler;
- this.rateLimiter = rateLimiter;
- this.response = response;
- this.updateDomainXml =
- readResourceUtf8(RemoveAllDomainContactsAction.class, "domain_remove_contacts.xml");
- }
-
- @Override
- public void run() {
- checkState(
- tm().transact(() -> FeatureFlag.isActiveNow(MINIMUM_DATASET_CONTACTS_PROHIBITED)),
- "Minimum dataset migration must be completed prior to running this action");
- response.setContentType(PLAIN_TEXT_UTF_8);
-
- Callable runner =
- () -> {
- try {
- runLocked();
- response.setStatus(SC_OK);
- } catch (Exception e) {
- logger.atSevere().withCause(e).log("Errored out during execution.");
- response.setStatus(SC_INTERNAL_SERVER_ERROR);
- response.setPayload(String.format("Errored out with cause: %s", e));
- }
- return null;
- };
-
- if (!lockHandler.executeWithLocks(runner, null, Duration.standardHours(1), LOCK_NAME)) {
- // Send a 200-series status code to prevent this conflicting action from retrying.
- response.setStatus(SC_NO_CONTENT);
- response.setPayload("Could not acquire lock; already running?");
- }
- }
-
- private void runLocked() {
- logger.atInfo().log("Removing contacts on all active domains.");
-
- List domainRepoIdsBatch;
- do {
- domainRepoIdsBatch =
- tm().>transact(
- () ->
- tm().getEntityManager()
- .createQuery(
- """
- SELECT repoId FROM Domain WHERE deletionTime = :end_of_time AND NOT (
- adminContact IS NULL AND billingContact IS NULL
- AND registrantContact IS NULL AND techContact IS NULL)
- """)
- .setParameter("end_of_time", END_OF_TIME)
- .setMaxResults(BATCH_SIZE)
- .getResultList());
-
- for (String domainRepoId : domainRepoIdsBatch) {
- rateLimiter.acquire();
- runDomainUpdateFlow(domainRepoId);
- }
- } while (!domainRepoIdsBatch.isEmpty());
- String msg =
- String.format(
- "Finished; %d domains were successfully updated and %d errored out.",
- successes, failures);
- logger.at(failures == 0 ? Level.INFO : Level.WARNING).log(msg);
- response.setPayload(msg);
- }
-
- private void runDomainUpdateFlow(String repoId) {
- // Create a new transaction that the flow's execution will be enlisted in that loads the domain
- // transactionally. This way we can ensure that nothing else has modified the domain in question
- // in the intervening period since the query above found it. If a single domain update fails
- // permanently, log it and move on to not block processing all the other domains.
- try {
- boolean success = tm().transact(() -> runDomainUpdateFlowInner(repoId));
- if (success) {
- successes++;
- } else {
- failures++;
- }
- } catch (Throwable t) {
- logger.atWarning().withCause(t).log(
- "Failed updating domain with repoId %s; skipping.", repoId);
- }
- }
-
- /**
- * Runs the actual domain update flow and returns whether the contact removals were successful.
- */
- private boolean runDomainUpdateFlowInner(String repoId) {
- Domain domain = tm().loadByKey(VKey.create(Domain.class, repoId));
- if (!domain.getDeletionTime().equals(END_OF_TIME)) {
- // Domain has been deleted since the action began running; nothing further to be
- // done here.
- logger.atInfo().log("Nothing to process for deleted domain '%s'.", domain.getDomainName());
- return false;
- }
- logger.atInfo().log("Attempting to remove contacts on domain '%s'.", domain.getDomainName());
-
- StringBuilder sb = new StringBuilder();
- ImmutableMap, Contact> contacts =
- tm().loadByKeys(
- domain.getContacts().stream()
- .map(DesignatedContact::getContactKey)
- .collect(ImmutableSet.toImmutableSet()));
-
- // Collect all the (non-registrant) contacts referenced by the domain and compile an EPP XML
- // string that removes each one.
- for (DesignatedContact designatedContact : domain.getContacts()) {
- @Nullable Contact contact = contacts.get(designatedContact.getContactKey());
- if (contact == null) {
- logger.atWarning().log(
- "Domain '%s' referenced contact with repo ID '%s' that couldn't be" + " loaded.",
- domain.getDomainName(), designatedContact.getContactKey().getKey());
- continue;
- }
- sb.append(
- String.format(
- CONTACT_FMT,
- Ascii.toLowerCase(designatedContact.getType().name()),
- contact.getContactId()))
- .append("\n");
- }
-
- String compiledXml =
- updateDomainXml
- .replace("%DOMAIN%", domain.getDomainName())
- .replace("%CONTACTS%", sb.toString());
- EppOutput output =
- eppController.handleEppCommand(
- new StatelessRequestSessionMetadata(
- registryAdminClientId, ProtocolDefinition.getVisibleServiceExtensionUris()),
- new PasswordOnlyTransportCredentials(),
- EppRequestSource.BACKEND,
- false,
- true,
- compiledXml.getBytes(US_ASCII));
- if (output.isSuccess()) {
- logger.atInfo().log(
- "Successfully removed contacts from domain '%s'.", domain.getDomainName());
- } else {
- logger.atWarning().log(
- "Failed removing contacts from domain '%s' with error %s.",
- domain.getDomainName(), new String(marshalWithLenientRetry(output), US_ASCII));
- }
- return output.isSuccess();
- }
-}
diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java
index 58992d4b54e..1117982f877 100644
--- a/core/src/main/java/google/registry/module/RequestComponent.java
+++ b/core/src/main/java/google/registry/module/RequestComponent.java
@@ -23,7 +23,6 @@
import google.registry.batch.DeleteProberDataAction;
import google.registry.batch.ExpandBillingRecurrencesAction;
import google.registry.batch.RelockDomainAction;
-import google.registry.batch.RemoveAllDomainContactsAction;
import google.registry.batch.ResaveAllEppResourcesPipelineAction;
import google.registry.batch.ResaveEntityAction;
import google.registry.batch.SendExpiringCertificateNotificationEmailAction;
@@ -267,8 +266,6 @@ interface RequestComponent {
ReadinessProbeActionFrontend readinessProbeActionFrontend();
- RemoveAllDomainContactsAction removeAllDomainContactsAction();
-
RdapAutnumAction rdapAutnumAction();
RdapDomainAction rdapDomainAction();
diff --git a/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java b/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java
deleted file mode 100644
index 2d358c9e779..00000000000
--- a/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright 2025 The Nomulus Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package google.registry.batch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static google.registry.model.common.FeatureFlag.FeatureName.MINIMUM_DATASET_CONTACTS_PROHIBITED;
-import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE;
-import static google.registry.testing.DatabaseHelper.createTld;
-import static google.registry.testing.DatabaseHelper.loadByEntity;
-import static google.registry.testing.DatabaseHelper.newDomain;
-import static google.registry.testing.DatabaseHelper.persistActiveContact;
-import static google.registry.testing.DatabaseHelper.persistResource;
-import static google.registry.util.DateTimeUtils.START_OF_TIME;
-import static org.mockito.Mockito.mock;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.util.concurrent.RateLimiter;
-import google.registry.flows.DaggerEppTestComponent;
-import google.registry.flows.EppController;
-import google.registry.flows.EppTestComponent.FakesAndMocksModule;
-import google.registry.model.common.FeatureFlag;
-import google.registry.model.contact.Contact;
-import google.registry.model.domain.Domain;
-import google.registry.persistence.transaction.JpaTestExtensions;
-import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension;
-import google.registry.testing.FakeClock;
-import google.registry.testing.FakeLockHandler;
-import google.registry.testing.FakeResponse;
-import java.util.Optional;
-import org.joda.time.DateTime;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-
-/** Unit tests for {@link RemoveAllDomainContactsAction}. */
-class RemoveAllDomainContactsActionTest {
-
- @RegisterExtension
- final JpaIntegrationTestExtension jpa =
- new JpaTestExtensions.Builder().buildIntegrationTestExtension();
-
- private final FakeResponse response = new FakeResponse();
- private final RateLimiter rateLimiter = mock(RateLimiter.class);
- private RemoveAllDomainContactsAction action;
-
- @BeforeEach
- void beforeEach() {
- createTld("tld");
- persistResource(
- new FeatureFlag.Builder()
- .setFeatureName(MINIMUM_DATASET_CONTACTS_PROHIBITED)
- .setStatusMap(ImmutableSortedMap.of(START_OF_TIME, ACTIVE))
- .build());
- EppController eppController =
- DaggerEppTestComponent.builder()
- .fakesAndMocksModule(FakesAndMocksModule.create(new FakeClock()))
- .build()
- .startRequest()
- .eppController();
- action =
- new RemoveAllDomainContactsAction(
- eppController, "NewRegistrar", new FakeLockHandler(true), rateLimiter, response);
- }
-
- @Test
- void test_removesAllContactsFromMultipleDomains_andDoesntModifyDomainThatHasNoContacts() {
- Contact c1 = persistActiveContact("contact12345");
- Domain d1 = persistResource(newDomain("foo.tld", c1));
- assertThat(d1.getAllContacts()).hasSize(3);
- Contact c2 = persistActiveContact("contact23456");
- Domain d2 = persistResource(newDomain("bar.tld", c2));
- assertThat(d2.getAllContacts()).hasSize(3);
- Domain d3 =
- persistResource(
- newDomain("baz.tld")
- .asBuilder()
- .setRegistrant(Optional.empty())
- .setContacts(ImmutableSet.of())
- .build());
- assertThat(d3.getAllContacts()).isEmpty();
- DateTime lastUpdate = d3.getUpdateTimestamp().getTimestamp();
-
- action.run();
- assertThat(loadByEntity(d1).getAllContacts()).isEmpty();
- assertThat(loadByEntity(d2).getAllContacts()).isEmpty();
- assertThat(loadByEntity(d3).getUpdateTimestamp().getTimestamp()).isEqualTo(lastUpdate);
- }
-
- @Test
- void test_removesContacts_onDomainsThatOnlyPartiallyHaveContacts() {
- Contact c1 = persistActiveContact("contact12345");
- Domain d1 =
- persistResource(
- newDomain("foo.tld", c1).asBuilder().setContacts(ImmutableSet.of()).build());
- assertThat(d1.getAllContacts()).hasSize(1);
- Contact c2 = persistActiveContact("contact23456");
- Domain d2 =
- persistResource(
- newDomain("bar.tld", c2).asBuilder().setRegistrant(Optional.empty()).build());
- assertThat(d2.getAllContacts()).hasSize(2);
-
- action.run();
- assertThat(loadByEntity(d1).getAllContacts()).isEmpty();
- assertThat(loadByEntity(d2).getAllContacts()).isEmpty();
- }
-}
diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt
index ae365ca2dc5..758eb111041 100644
--- a/core/src/test/resources/google/registry/module/routing.txt
+++ b/core/src/test/resources/google/registry/module/routing.txt
@@ -44,7 +44,6 @@ BACKEND /_dr/task/readDnsRefreshRequests ReadDnsRefreshReques
BACKEND /_dr/task/refreshDnsForAllDomains RefreshDnsForAllDomainsAction GET n APP ADMIN
BACKEND /_dr/task/refreshDnsOnHostRename RefreshDnsOnHostRenameAction POST n APP ADMIN
BACKEND /_dr/task/relockDomain RelockDomainAction POST y APP ADMIN
-BACKEND /_dr/task/removeAllDomainContacts RemoveAllDomainContactsAction POST n APP ADMIN
BACKEND /_dr/task/resaveAllEppResourcesPipeline ResaveAllEppResourcesPipelineAction GET n APP ADMIN
BACKEND /_dr/task/resaveEntity ResaveEntityAction POST n APP ADMIN
BACKEND /_dr/task/sendExpiringCertificateNotificationEmail SendExpiringCertificateNotificationEmailAction GET n APP ADMIN