diff --git a/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java b/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java
new file mode 100644
index 00000000000..7adc1861372
--- /dev/null
+++ b/core/src/main/java/google/registry/batch/RemoveAllDomainContactsAction.java
@@ -0,0 +1,232 @@
+// 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 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 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 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,
+ Response response) {
+ this.eppController = eppController;
+ this.registryAdminClientId = registryAdminClientId;
+ this.lockHandler = lockHandler;
+ 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());
+
+ domainRepoIdsBatch.forEach(this::runDomainUpdateFlow);
+ } 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.
+ boolean success = tm().transact(() -> runDomainUpdateFlowInner(repoId));
+ if (success) {
+ successes++;
+ } else {
+ failures++;
+ }
+ }
+
+ /**
+ * 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 9fca8fdf948..7b1a6223bfd 100644
--- a/core/src/main/java/google/registry/module/RequestComponent.java
+++ b/core/src/main/java/google/registry/module/RequestComponent.java
@@ -23,6 +23,7 @@
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;
@@ -270,6 +271,8 @@ interface RequestComponent {
ReadinessProbeActionFrontend readinessProbeActionFrontend();
+ RemoveAllDomainContactsAction removeAllDomainContactsAction();
+
RdapAutnumAction rdapAutnumAction();
RdapDomainAction rdapDomainAction();
diff --git a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
index f0ba49c6c3f..621fcdf15f8 100644
--- a/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
+++ b/core/src/main/java/google/registry/module/backend/BackendRequestComponent.java
@@ -23,6 +23,7 @@
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;
@@ -153,6 +154,8 @@ public interface BackendRequestComponent {
RelockDomainAction relockDomainAction();
+ RemoveAllDomainContactsAction removeAllDomainContactsAction();
+
ResaveAllEppResourcesPipelineAction resaveAllEppResourcesPipelineAction();
ResaveEntityAction resaveEntityAction();
diff --git a/core/src/main/resources/google/registry/batch/domain_remove_contacts.xml b/core/src/main/resources/google/registry/batch/domain_remove_contacts.xml
new file mode 100644
index 00000000000..a10e528c7fc
--- /dev/null
+++ b/core/src/main/resources/google/registry/batch/domain_remove_contacts.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ %DOMAIN%
+
+ %CONTACTS%
+
+
+
+
+
+
+
+
+ Registry minimum data set phase 3: Removed all contacts from domain.
+ false
+
+
+ ABC-12345
+
+
diff --git a/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java b/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java
new file mode 100644
index 00000000000..6f7cbbaa703
--- /dev/null
+++ b/core/src/test/java/google/registry/batch/RemoveAllDomainContactsActionTest.java
@@ -0,0 +1,116 @@
+// 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 com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
+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 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), 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/backend/backend_routing.txt b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
index 4c7e40f365f..6de8ea5b4da 100644
--- a/core/src/test/resources/google/registry/module/backend/backend_routing.txt
+++ b/core/src/test/resources/google/registry/module/backend/backend_routing.txt
@@ -26,6 +26,7 @@ BACKEND /_dr/task/rdeUpload RdeUploadAction
BACKEND /_dr/task/readDnsRefreshRequests ReadDnsRefreshRequestsAction POST y 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
diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt
index 5250a322c1d..cf13db90900 100644
--- a/core/src/test/resources/google/registry/module/routing.txt
+++ b/core/src/test/resources/google/registry/module/routing.txt
@@ -44,6 +44,7 @@ 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