You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/06/04 22:46:58 UTC

(causeway) 39/42: CAUSEWAY-4010: adds persistent replay mapping listener

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch CAUSEWAY-4010
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit d9f918630ed69c21c1b6fad9e9dbf40babb8ee2f
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Jun 4 23:11:46 2026 +0100

    CAUSEWAY-4010: adds persistent replay mapping listener
---
 .../core/config/CausewayConfiguration.java         |  28 +++-
 .../commandlog/applib/app/CommandLogMenu.java      |  27 ++++
 .../applib/dom/CommandReplayResultMapping.java     | 130 ++++++++++++++++
 .../dom/CommandReplayResultMapping.layout.xml      |  41 ++++++
 .../dom/CommandReplayResultMappingRepository.java  |  39 +++++
 ...mmandReplayResultMappingRepositoryAbstract.java |  76 ++++++++++
 .../spi/CommandReplayMappingListenerDefault.java   |   6 +
 ...=> CommandReplayMappingListenerPersistent.java} |  52 ++++---
 .../integtest/CommandLog_IntegTestAbstract.java    |  35 +++++
 .../CommandReplayMappingListenerDefaultTest.java   |  14 ++
 ...ommandReplayMappingListenerPersistentTest.java} | 164 ++++++++++++++++-----
 .../CausewayModuleExtCommandLogPersistenceJdo.java |   9 +-
 .../jdo/dom/CommandReplayResultMapping.java        |  93 ++++++++++++
 .../dom/CommandReplayResultMappingRepository.java  |  47 ++++++
 .../CausewayModuleExtCommandLogPersistenceJpa.java |  12 +-
 .../jpa/dom/CommandReplayResultMapping.java        | 105 +++++++++++++
 .../dom/CommandReplayResultMappingRepository.java  |  47 ++++++
 .../tasks.md                                       |  56 +++----
 18 files changed, 888 insertions(+), 93 deletions(-)

diff --git a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
index 350ffe943d4..7dcc74cac90 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
@@ -3984,7 +3984,26 @@ public enum PersistPolicy {
             public static class ReplayResultMapping {
 
                 /**
-                 * How the default command replay mapping listener handles a conflicting replay result mapping.
+                 * Which built-in command replay mapping listener storage strategy to use.
+                 *
+                 * <p>
+                 *     External property: {@code causeway.extensions.command-log.replay-result-mapping.storage-strategy}.
+                 *     Accepted values are {@code IN_MEMORY} and {@code PERSISTENT}.
+                 * </p>
+                 */
+                public enum StorageStrategy {
+                    /**
+                     * Store replay result mappings in the default listener instance only.
+                     */
+                    IN_MEMORY,
+                    /**
+                     * Store replay result mappings in the configured command log persistence store.
+                     */
+                    PERSISTENT;
+                }
+
+                /**
+                 * How the built-in command replay mapping listener handles a conflicting replay result mapping.
                  *
                  * <p>
                  *     External property: {@code causeway.extensions.command-log.replay-result-mapping.on-conflict-policy}.
@@ -4007,7 +4026,12 @@ public enum OnConflictPolicy {
                 }
 
                 /**
-                 * If replay result mapping conflicts, what should the default listener do?
+                 * Which built-in replay mapping listener storage strategy should be used?
+                 */
+                private StorageStrategy storageStrategy = StorageStrategy.IN_MEMORY;
+
+                /**
+                 * If replay result mapping conflicts, what should the built-in listener do?
                  */
                 private OnConflictPolicy onConflictPolicy = OnConflictPolicy.THROW_EXCEPTION;
             }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
index 34172393184..3e4ccd62564 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
@@ -46,6 +46,8 @@
 import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager_changeLimit;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
@@ -80,6 +82,7 @@ public static abstract class ActionDomainEvent<T>
 
 
     final CommandLogEntryRepository commandLogEntryRepository;
+    final Optional<CommandReplayResultMappingRepository> commandReplayResultMappingRepository;
     final ClockService clockService;
     final ReplayContext replayContext;
 
@@ -166,6 +169,30 @@ public DomainEvent() { }
         }
     }
 
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = findReplayResultMappings.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            semantics = SemanticsOf.SAFE,
+            typeOf = CommandReplayResultMapping.class
+    )
+    @ActionLayout(cssClassFa = "fa-search", sequence="45")
+    public class findReplayResultMappings {
+        public class DomainEvent extends ActionDomainEvent<findReplayResultMappings> {
+            public DomainEvent() { }
+        }
+
+        @MemberSupport public List<? extends CommandReplayResultMapping> act() {
+            return commandReplayResultMappingRepository
+                    .map(CommandReplayResultMappingRepository::findAll)
+                    .orElseGet(List::of);
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
     @Action(
             commandPublishing = Publishing.DISABLED,
             domainEvent = exportManager.DomainEvent.class,
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java
new file mode 100644
index 00000000000..d13ef46c2d3
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java
@@ -0,0 +1,130 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.applib.dom;
+
+import javax.inject.Named;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+import lombok.NoArgsConstructor;
+import lombok.experimental.UtilityClass;
+
+@Named(CommandReplayResultMapping.LOGICAL_TYPE_NAME)
+@DomainObject(
+        editing = Editing.DISABLED,
+        entityChangePublishing = Publishing.DISABLED
+)
+@DomainObjectLayout(
+        titleUiEvent = CommandReplayResultMapping.TitleUiEvent.class,
+        iconUiEvent = CommandReplayResultMapping.IconUiEvent.class,
+        cssClassUiEvent = CommandReplayResultMapping.CssClassUiEvent.class,
+        layoutUiEvent = CommandReplayResultMapping.LayoutUiEvent.class
+)
+@NoArgsConstructor
+public abstract class CommandReplayResultMapping {
+
+    public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayResultMapping";
+    public static final String SCHEMA = CausewayModuleExtCommandLogApplib.SCHEMA;
+    public static final String TABLE = "CommandReplayResultMapping";
+
+    public static class TitleUiEvent extends CausewayModuleExtCommandLogApplib.TitleUiEvent<CommandReplayResultMapping> { }
+    public static class IconUiEvent extends CausewayModuleExtCommandLogApplib.IconUiEvent<CommandReplayResultMapping> { }
+    public static class CssClassUiEvent extends CausewayModuleExtCommandLogApplib.CssClassUiEvent<CommandReplayResultMapping> { }
+    public static class LayoutUiEvent extends CausewayModuleExtCommandLogApplib.LayoutUiEvent<CommandReplayResultMapping> { }
+
+    @UtilityClass
+    public static class Nq {
+        public static final String FIND = LOGICAL_TYPE_NAME + ".find";
+        public static final String FIND_BY_RECORDED_BOOKMARK = LOGICAL_TYPE_NAME + ".findByRecordedBookmark";
+    }
+
+    @Programmatic
+    public void init(final Bookmark recordedBookmark, final Bookmark actualBookmark) {
+        setRecordedLogicalTypeName(recordedBookmark.getLogicalTypeName());
+        setRecordedIdentifier(recordedBookmark.getIdentifier());
+        setActualLogicalTypeName(actualBookmark.getLogicalTypeName());
+        setActualIdentifier(actualBookmark.getIdentifier());
+    }
+
+    @Programmatic
+    public Bookmark getRecordedBookmark() {
+        return Bookmark.forLogicalTypeNameAndIdentifier(getRecordedLogicalTypeName(), getRecordedIdentifier());
+    }
+
+    @Programmatic
+    public Bookmark getActualBookmark() {
+        return Bookmark.forLogicalTypeNameAndIdentifier(getActualLogicalTypeName(), getActualIdentifier());
+    }
+
+    @Property
+    @RecordedLogicalTypeName
+    public abstract String getRecordedLogicalTypeName();
+    public abstract void setRecordedLogicalTypeName(String recordedLogicalTypeName);
+
+    @Property
+    @RecordedIdentifier
+    public abstract String getRecordedIdentifier();
+    public abstract void setRecordedIdentifier(String recordedIdentifier);
+
+    @Property
+    @ActualLogicalTypeName
+    public abstract String getActualLogicalTypeName();
+    public abstract void setActualLogicalTypeName(String actualLogicalTypeName);
+
+    @Property
+    @ActualIdentifier
+    public abstract String getActualIdentifier();
+    public abstract void setActualIdentifier(String actualIdentifier);
+
+    public @interface RecordedLogicalTypeName {
+        String NAME = "recordedLogicalTypeName";
+        int MAX_LENGTH = 255;
+        boolean NULLABLE = false;
+        String ALLOWS_NULL = "false";
+    }
+
+    public @interface RecordedIdentifier {
+        String NAME = "recordedIdentifier";
+        int MAX_LENGTH = 2000;
+        boolean NULLABLE = false;
+        String ALLOWS_NULL = "false";
+    }
+
+    public @interface ActualLogicalTypeName {
+        String NAME = "actualLogicalTypeName";
+        int MAX_LENGTH = 255;
+        boolean NULLABLE = false;
+        String ALLOWS_NULL = "false";
+    }
+
+    public @interface ActualIdentifier {
+        String NAME = "actualIdentifier";
+        int MAX_LENGTH = 2000;
+        boolean NULLABLE = false;
+        String ALLOWS_NULL = "false";
+    }
+
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.xml
new file mode 100644
index 00000000000..e6d1718bc2b
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you 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.
+  -->
+<bs:grid xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd" xmlns:c="https://causeway.apache.org/applib/layout/component" xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+    <bs:row>
+        <bs:col span="12" unreferencedActions="true">
+            <c:domainObject bookmarking="AS_ROOT"/>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="6">
+            <c:fieldSet name="Recorded Bookmark" id="recorded">
+                <c:property id="recordedLogicalTypeName"/>
+                <c:property id="recordedIdentifier"/>
+            </c:fieldSet>
+        </bs:col>
+        <bs:col span="6">
+            <c:fieldSet name="Actual Bookmark" id="actual">
+                <c:property id="actualLogicalTypeName"/>
+                <c:property id="actualIdentifier"/>
+            </c:fieldSet>
+        </bs:col>
+    </bs:row>
+</bs:grid>
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepository.java
new file mode 100644
index 00000000000..bf72c63dc24
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepository.java
@@ -0,0 +1,39 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+
+/**
+ * Provides supporting functionality for querying and persisting replay result mappings.
+ *
+ * @since 2.1 {@index}
+ */
+public interface CommandReplayResultMappingRepository {
+
+    Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark recordedBookmark);
+
+    List<? extends CommandReplayResultMapping> findAll();
+
+    CommandReplayResultMapping createAndPersist(final Bookmark recordedBookmark, final Bookmark actualBookmark);
+
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java
new file mode 100644
index 00000000000..5e32fcb3482
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java
@@ -0,0 +1,76 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.List;
+import java.util.Optional;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+
+import org.apache.causeway.applib.query.Query;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.factory.FactoryService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+import org.apache.causeway.commons.internal.base._Casts;
+
+/**
+ * Provides supporting functionality for querying and persisting replay result mapping entities.
+ *
+ * @since 2.1 {@index}
+ */
+public abstract class CommandReplayResultMappingRepositoryAbstract<C extends CommandReplayResultMapping>
+implements CommandReplayResultMappingRepository {
+
+    @Inject Provider<RepositoryService> repositoryServiceProvider;
+    @Inject FactoryService factoryService;
+
+    private final Class<C> entityClass;
+
+    protected CommandReplayResultMappingRepositoryAbstract(final Class<C> entityClass) {
+        this.entityClass = entityClass;
+    }
+
+    @Override
+    public Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark recordedBookmark) {
+        return _Casts.uncheckedCast(repositoryService().firstMatch(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND_BY_RECORDED_BOOKMARK)
+                        .withParameter("recordedLogicalTypeName", recordedBookmark.getLogicalTypeName())
+                        .withParameter("recordedIdentifier", recordedBookmark.getIdentifier())));
+    }
+
+    @Override
+    public List<? extends CommandReplayResultMapping> findAll() {
+        return _Casts.uncheckedCast(repositoryService().allMatches(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND)));
+    }
+
+    @Override
+    public CommandReplayResultMapping createAndPersist(final Bookmark recordedBookmark, final Bookmark actualBookmark) {
+        C mapping = factoryService.detachedEntity(entityClass);
+        mapping.init(recordedBookmark, actualBookmark);
+        repositoryService().persistAndFlush(mapping);
+        return mapping;
+    }
+
+    private RepositoryService repositoryService() {
+        return repositoryServiceProvider.get();
+    }
+
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
index 39a58bdce6d..47045684b84 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
@@ -23,6 +23,7 @@
 import java.util.Optional;
 
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
@@ -91,6 +92,11 @@ public static class BeanFactory {
 
         @Bean
         @ConditionalOnMissingBean(CommandReplayMappingListener.class)
+        @ConditionalOnProperty(
+                prefix = "causeway.extensions.command-log.replay-result-mapping",
+                name = "storage-strategy",
+                havingValue = "IN_MEMORY",
+                matchIfMissing = true)
         CommandReplayMappingListener commandReplayMappingListenerDefault(
                 final CausewayConfiguration causewayConfiguration) {
             return new CommandReplayMappingListenerDefault(causewayConfiguration
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java
similarity index 57%
copy from extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java
index 39a58bdce6d..77ee5c94f48 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefault.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java
@@ -18,11 +18,11 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.spi;
 
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
@@ -30,25 +30,26 @@
 import org.apache.causeway.core.config.CausewayConfiguration;
 import org.apache.causeway.core.config.CausewayConfiguration.Extensions.CommandLog.ReplayResultMapping.OnConflictPolicy;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 
 import lombok.extern.log4j.Log4j2;
 
 /**
- * Default in-memory implementation of {@link CommandReplayMappingListener}.
+ * Persistent implementation of {@link CommandReplayMappingListener}.
  *
  * @since 2.1 {@index}
  */
 @Log4j2
-public class CommandReplayMappingListenerDefault implements CommandReplayMappingListener {
+public class CommandReplayMappingListenerPersistent implements CommandReplayMappingListener {
 
-    private final Map<Bookmark, Bookmark> actualBookmarkByRecordedBookmark = new HashMap<>();
+    private final CommandReplayResultMappingRepository commandReplayResultMappingRepository;
     private final OnConflictPolicy onConflictPolicy;
 
-    public CommandReplayMappingListenerDefault() {
-        this(OnConflictPolicy.THROW_EXCEPTION);
-    }
-
-    CommandReplayMappingListenerDefault(final OnConflictPolicy onConflictPolicy) {
+    public CommandReplayMappingListenerPersistent(
+            final CommandReplayResultMappingRepository commandReplayResultMappingRepository,
+            final OnConflictPolicy onConflictPolicy) {
+        this.commandReplayResultMappingRepository = commandReplayResultMappingRepository;
         this.onConflictPolicy = onConflictPolicy;
     }
 
@@ -56,7 +57,8 @@ public CommandReplayMappingListenerDefault() {
     public Optional<Bookmark> remap(
             final CommandLogEntry commandLogEntry,
             final Bookmark recordedBookmark) {
-        return Optional.ofNullable(actualBookmarkByRecordedBookmark.get(recordedBookmark));
+        return commandReplayResultMappingRepository.findByRecordedBookmark(recordedBookmark)
+                .map(CommandReplayResultMapping::getActualBookmark);
     }
 
     @Override
@@ -67,11 +69,13 @@ public void onReplayResultMapped(
         if(recordedResult.equals(actualResult)) {
             return;
         }
-        final Bookmark existingActualResult = actualBookmarkByRecordedBookmark.get(recordedResult);
-        if(existingActualResult == null) {
-            actualBookmarkByRecordedBookmark.put(recordedResult, actualResult);
+        final Optional<CommandReplayResultMapping> existingMappingIfAny =
+                commandReplayResultMappingRepository.findByRecordedBookmark(recordedResult);
+        if(existingMappingIfAny.isEmpty()) {
+            commandReplayResultMappingRepository.createAndPersist(recordedResult, actualResult);
             return;
         }
+        final Bookmark existingActualResult = existingMappingIfAny.get().getActualBookmark();
         if(!existingActualResult.equals(actualResult)) {
             final String message = String.format(
                     "Recorded result bookmark '%s' was already mapped to actual bookmark '%s', cannot remap to '%s'",
@@ -90,14 +94,22 @@ public void onReplayResultMapped(
     public static class BeanFactory {
 
         @Bean
+        @ConditionalOnBean(CommandReplayResultMappingRepository.class)
         @ConditionalOnMissingBean(CommandReplayMappingListener.class)
-        CommandReplayMappingListener commandReplayMappingListenerDefault(
+        @ConditionalOnProperty(
+                prefix = "causeway.extensions.command-log.replay-result-mapping",
+                name = "storage-strategy",
+                havingValue = "PERSISTENT")
+        CommandReplayMappingListener commandReplayMappingListenerPersistent(
+                final CommandReplayResultMappingRepository commandReplayResultMappingRepository,
                 final CausewayConfiguration causewayConfiguration) {
-            return new CommandReplayMappingListenerDefault(causewayConfiguration
-                    .getExtensions()
-                    .getCommandLog()
-                    .getReplayResultMapping()
-                    .getOnConflictPolicy());
+            return new CommandReplayMappingListenerPersistent(
+                    commandReplayResultMappingRepository,
+                    causewayConfiguration
+                            .getExtensions()
+                            .getCommandLog()
+                            .getReplayResultMapping()
+                            .getOnConflictPolicy());
         }
 
     }
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
index 25f08554460..ed271259221 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
@@ -41,7 +41,10 @@
 import org.apache.causeway.core.config.presets.CausewayPresets;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerPersistent;
+import org.apache.causeway.core.config.CausewayConfiguration.Extensions.CommandLog.ReplayResultMapping.OnConflictPolicy;
 import org.apache.causeway.extensions.commandlog.applib.integtest.model.Counter;
 import org.apache.causeway.extensions.commandlog.applib.integtest.model.CounterRepository;
 import org.apache.causeway.extensions.commandlog.applib.integtest.model.Counter_bumpUsingMixin;
@@ -89,6 +92,37 @@ void beforeEach() {
     protected abstract Counter newCounter(String name);
 
 
+    @Test
+    void replay_result_mapping_repository_and_persistent_listener() {
+        String suffix = UUID.randomUUID().toString();
+        Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "recorded-" + suffix);
+        Bookmark actualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "actual-" + suffix);
+        Bookmark conflictingActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "conflicting-" + suffix);
+
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                commandReplayResultMappingRepository, OnConflictPolicy.THROW_EXCEPTION);
+
+        listener.onReplayResultMapped(recordedResult, actualResult, null);
+        listener.onReplayResultMapped(recordedResult, actualResult, null);
+
+        assertThat(commandReplayResultMappingRepository.findByRecordedBookmark(recordedResult))
+                .isPresent()
+                .get()
+                .extracting("actualBookmark")
+                .isEqualTo(actualResult);
+        assertThat(commandReplayResultMappingRepository.findAll())
+                .anySatisfy(mapping -> assertThat(mapping.getRecordedBookmark()).isEqualTo(recordedResult));
+        assertThat(listener.remap(null, recordedResult)).contains(actualResult);
+
+        Assertions.assertThatThrownBy(() -> listener.onReplayResultMapped(recordedResult, conflictingActualResult, null))
+                .isInstanceOf(IllegalStateException.class);
+        assertThat(commandReplayResultMappingRepository.findByRecordedBookmark(recordedResult))
+                .isPresent()
+                .get()
+                .extracting("actualBookmark")
+                .isEqualTo(actualResult);
+    }
+
     @Test
     void invoke_mixin() {
 
@@ -477,6 +511,7 @@ void test_all_the_repository_methods() {
     }
 
     @Inject CommandLogEntryRepository commandLogEntryRepository;
+    @Inject CommandReplayResultMappingRepository commandReplayResultMappingRepository;
     @Inject SudoService sudoService;
     @Inject ClockService clockService;
     @Inject InteractionService interactionService;
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
index 4cd09e11d6d..3cc3dee61e3 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
@@ -22,12 +22,14 @@
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.Mockito.mock;
 
+import java.util.Map;
 import java.util.Optional;
 
 import org.junit.jupiter.api.Test;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.MapPropertySource;
 import org.springframework.core.env.StandardEnvironment;
 
 import org.apache.causeway.applib.services.bookmark.Bookmark;
@@ -140,6 +142,18 @@ void autoconfiguration_backs_off_when_custom_listener_exists() {
         }
     }
 
+    @Test
+    void autoconfiguration_does_not_create_in_memory_listener_when_persistent_storage_is_configured() {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
+            context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", Map.of(
+                    "causeway.extensions.command-log.replay-result-mapping.storage-strategy", "PERSISTENT")));
+            context.register(CausewayConfigurationForTest.class, CommandReplayMappingListenerDefault.BeanFactory.class);
+            context.refresh();
+
+            assertThat(context.getBeansOfType(CommandReplayMappingListener.class)).isEmpty();
+        }
+    }
+
     @Test
     void autoconfiguration_uses_configured_conflict_policy() {
         try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
similarity index 53%
copy from extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
copy to extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
index 4cd09e11d6d..95d614d84d0 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerDefaultTest.java
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
@@ -22,24 +22,33 @@
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.Mockito.mock;
 
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 
 import org.junit.jupiter.api.Test;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.MapPropertySource;
 import org.springframework.core.env.StandardEnvironment;
 
 import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.core.config.CausewayConfiguration;
 import org.apache.causeway.core.config.CausewayConfiguration.Extensions.CommandLog.ReplayResultMapping.OnConflictPolicy;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 
-class CommandReplayMappingListenerDefaultTest {
+class CommandReplayMappingListenerPersistentTest {
 
     @Test
     void records_result_mapping_and_returns_it_from_remap() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        FakeRepository repository = new FakeRepository();
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                repository, OnConflictPolicy.THROW_EXCEPTION);
         CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
         Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
         Bookmark actualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
@@ -47,22 +56,28 @@ void records_result_mapping_and_returns_it_from_remap() {
         listener.onReplayResultMapped(recordedResult, actualResult, commandLogEntry);
 
         assertThat(listener.remap(commandLogEntry, recordedResult)).contains(actualResult);
+        assertThat(repository.findAll()).hasSize(1);
     }
 
     @Test
     void equal_recorded_and_actual_bookmark_is_not_recorded() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        FakeRepository repository = new FakeRepository();
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                repository, OnConflictPolicy.THROW_EXCEPTION);
         CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
         Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
 
         listener.onReplayResultMapped(recordedResult, recordedResult, commandLogEntry);
 
         assertThat(listener.remap(commandLogEntry, recordedResult)).isEmpty();
+        assertThat(repository.findAll()).isEmpty();
     }
 
     @Test
     void repeated_recorded_bookmark_mapping_with_same_actual_bookmark_is_idempotent() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        FakeRepository repository = new FakeRepository();
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                repository, OnConflictPolicy.THROW_EXCEPTION);
         CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
         Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
         Bookmark actualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
@@ -71,11 +86,14 @@ void repeated_recorded_bookmark_mapping_with_same_actual_bookmark_is_idempotent(
         listener.onReplayResultMapped(recordedResult, actualResult, commandLogEntry);
 
         assertThat(listener.remap(commandLogEntry, recordedResult)).contains(actualResult);
+        assertThat(repository.findAll()).hasSize(1);
     }
 
     @Test
     void repeated_recorded_bookmark_mapping_with_different_actual_bookmark_is_rejected_by_default() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
+        FakeRepository repository = new FakeRepository();
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                repository, OnConflictPolicy.THROW_EXCEPTION);
         CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
         Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
         Bookmark firstActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
@@ -93,7 +111,9 @@ void repeated_recorded_bookmark_mapping_with_different_actual_bookmark_is_reject
 
     @Test
     void repeated_recorded_bookmark_mapping_with_different_actual_bookmark_is_logged_and_ignored_when_configured() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault(OnConflictPolicy.LOG_AND_CONTINUE);
+        FakeRepository repository = new FakeRepository();
+        CommandReplayMappingListenerPersistent listener = new CommandReplayMappingListenerPersistent(
+                repository, OnConflictPolicy.LOG_AND_CONTINUE);
         CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
         Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
         Bookmark firstActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
@@ -103,34 +123,38 @@ void repeated_recorded_bookmark_mapping_with_different_actual_bookmark_is_logged
         listener.onReplayResultMapped(recordedResult, secondActualResult, commandLogEntry);
 
         assertThat(listener.remap(commandLogEntry, recordedResult)).contains(firstActualResult);
+        assertThat(repository.findAll()).hasSize(1);
     }
 
     @Test
-    void unmapped_bookmark_returns_no_replacement() {
-        CommandReplayMappingListenerDefault listener = new CommandReplayMappingListenerDefault();
-        CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
-        Bookmark recordedBookmark = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
+    void autoconfiguration_creates_persistent_listener_when_configured() {
+        try (AnnotationConfigApplicationContext context = contextWithPersistentStorage(
+                CausewayConfigurationForTest.class,
+                RepositoryConfiguration.class,
+                CommandReplayMappingListenerPersistent.BeanFactory.class)) {
 
-        assertThat(listener.remap(commandLogEntry, recordedBookmark)).isEmpty();
+            assertThat(context.getBean(CommandReplayMappingListener.class))
+                    .isInstanceOf(CommandReplayMappingListenerPersistent.class);
+        }
     }
 
     @Test
-    void autoconfiguration_creates_default_listener_when_missing() {
-        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
+    void autoconfiguration_does_not_create_persistent_listener_without_repository() {
+        try (AnnotationConfigApplicationContext context = contextWithPersistentStorage(
                 CausewayConfigurationForTest.class,
-                CommandReplayMappingListenerDefault.BeanFactory.class)) {
+                CommandReplayMappingListenerPersistent.BeanFactory.class)) {
 
-            assertThat(context.getBean(CommandReplayMappingListener.class))
-                    .isInstanceOf(CommandReplayMappingListenerDefault.class);
+            assertThat(context.getBeansOfType(CommandReplayMappingListener.class)).isEmpty();
         }
     }
 
     @Test
     void autoconfiguration_backs_off_when_custom_listener_exists() {
-        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
+        try (AnnotationConfigApplicationContext context = contextWithPersistentStorage(
                 CausewayConfigurationForTest.class,
+                RepositoryConfiguration.class,
                 CustomListenerConfiguration.class,
-                CommandReplayMappingListenerDefault.BeanFactory.class)) {
+                CommandReplayMappingListenerPersistent.BeanFactory.class)) {
 
             assertThat(context.getBeansOfType(CommandReplayMappingListener.class))
                     .hasSize(1)
@@ -140,22 +164,13 @@ void autoconfiguration_backs_off_when_custom_listener_exists() {
         }
     }
 
-    @Test
-    void autoconfiguration_uses_configured_conflict_policy() {
-        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(
-                LogAndContinueCausewayConfigurationForTest.class,
-                CommandReplayMappingListenerDefault.BeanFactory.class)) {
-            CommandReplayMappingListener listener = context.getBean(CommandReplayMappingListener.class);
-            CommandLogEntry commandLogEntry = mock(CommandLogEntry.class);
-            Bookmark recordedResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "1");
-            Bookmark firstActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "2");
-            Bookmark secondActualResult = Bookmark.forLogicalTypeNameAndIdentifier("demoInvoice", "3");
-
-            listener.onReplayResultMapped(recordedResult, firstActualResult, commandLogEntry);
-            listener.onReplayResultMapped(recordedResult, secondActualResult, commandLogEntry);
-
-            assertThat(listener.remap(commandLogEntry, recordedResult)).contains(firstActualResult);
-        }
+    private static AnnotationConfigApplicationContext contextWithPersistentStorage(final Class<?>... configClasses) {
+        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+        context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", Map.of(
+                "causeway.extensions.command-log.replay-result-mapping.storage-strategy", "PERSISTENT")));
+        context.register(configClasses);
+        context.refresh();
+        return context;
     }
 
     @Configuration(proxyBeanMethods = false)
@@ -169,14 +184,13 @@ CausewayConfiguration causewayConfiguration() {
     }
 
     @Configuration(proxyBeanMethods = false)
-    static class LogAndContinueCausewayConfigurationForTest {
+    static class RepositoryConfiguration {
+
+        static final FakeRepository REPOSITORY = new FakeRepository();
 
         @Bean
-        CausewayConfiguration causewayConfiguration() {
-            CausewayConfiguration causewayConfiguration = new CausewayConfiguration(new StandardEnvironment(), Optional.empty());
-            causewayConfiguration.getExtensions().getCommandLog().getReplayResultMapping()
-                    .setOnConflictPolicy(OnConflictPolicy.LOG_AND_CONTINUE);
-            return causewayConfiguration;
+        CommandReplayResultMappingRepository commandReplayResultMappingRepository() {
+            return REPOSITORY;
         }
 
     }
@@ -193,4 +207,74 @@ CommandReplayMappingListener customCommandReplayMappingListener() {
 
     }
 
+    static class FakeRepository implements CommandReplayResultMappingRepository {
+
+        private final Map<Bookmark, CommandReplayResultMapping> mappings = new LinkedHashMap<>();
+
+        @Override
+        public Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark recordedBookmark) {
+            return Optional.ofNullable(mappings.get(recordedBookmark));
+        }
+
+        @Override
+        public List<? extends CommandReplayResultMapping> findAll() {
+            return new ArrayList<>(mappings.values());
+        }
+
+        @Override
+        public CommandReplayResultMapping createAndPersist(final Bookmark recordedBookmark, final Bookmark actualBookmark) {
+            FakeMapping mapping = new FakeMapping();
+            mapping.init(recordedBookmark, actualBookmark);
+            mappings.put(recordedBookmark, mapping);
+            return mapping;
+        }
+    }
+
+    static class FakeMapping extends CommandReplayResultMapping {
+
+        private String recordedLogicalTypeName;
+        private String recordedIdentifier;
+        private String actualLogicalTypeName;
+        private String actualIdentifier;
+
+        @Override
+        public String getRecordedLogicalTypeName() {
+            return recordedLogicalTypeName;
+        }
+
+        @Override
+        public void setRecordedLogicalTypeName(final String recordedLogicalTypeName) {
+            this.recordedLogicalTypeName = recordedLogicalTypeName;
+        }
+
+        @Override
+        public String getRecordedIdentifier() {
+            return recordedIdentifier;
+        }
+
+        @Override
+        public void setRecordedIdentifier(final String recordedIdentifier) {
+            this.recordedIdentifier = recordedIdentifier;
+        }
+
+        @Override
+        public String getActualLogicalTypeName() {
+            return actualLogicalTypeName;
+        }
+
+        @Override
+        public void setActualLogicalTypeName(final String actualLogicalTypeName) {
+            this.actualLogicalTypeName = actualLogicalTypeName;
+        }
+
+        @Override
+        public String getActualIdentifier() {
+            return actualIdentifier;
+        }
+
+        @Override
+        public void setActualIdentifier(final String actualIdentifier) {
+            this.actualIdentifier = actualIdentifier;
+        }
+    }
 }
diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/CausewayModuleExtCommandLogPersistenceJdo.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/CausewayModuleExtCommandLogPersistenceJdo.java
index 5d0ce7f1fd2..7d18f2a7d2b 100644
--- a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/CausewayModuleExtCommandLogPersistenceJdo.java
+++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/CausewayModuleExtCommandLogPersistenceJdo.java
@@ -22,8 +22,11 @@
 import org.springframework.context.annotation.Import;
 
 import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerPersistent;
 import org.apache.causeway.extensions.commandlog.jdo.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.jdo.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.jdo.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.jdo.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.persistence.jdo.datanucleus.CausewayModulePersistenceJdoDatanucleus;
 import org.apache.causeway.testing.fixtures.applib.fixturescripts.FixtureScript;
 import org.apache.causeway.testing.fixtures.applib.modules.ModuleWithFixtures;
@@ -40,9 +43,12 @@
 
         // @Service's
         CommandLogEntryRepository.class,
+        CommandReplayResultMappingRepository.class,
+        CommandReplayMappingListenerPersistent.BeanFactory.class,
 
         // entities
-        CommandLogEntry.class
+        CommandLogEntry.class,
+        CommandReplayResultMapping.class
 })
 public class CausewayModuleExtCommandLogPersistenceJdo {
 
@@ -57,6 +63,7 @@ public FixtureScript teardownFixture() {
         return new TeardownFixtureJdoAbstract() {
             @Override
             protected void execute(final ExecutionContext executionContext) {
+                deleteFrom(CommandReplayResultMapping.class);
                 deleteFrom(CommandLogEntry.class);
             }
         };
diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMapping.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMapping.java
new file mode 100644
index 00000000000..42ac70f9c9a
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMapping.java
@@ -0,0 +1,93 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.jdo.dom;
+
+import javax.inject.Named;
+import javax.jdo.annotations.Column;
+import javax.jdo.annotations.IdentityType;
+import javax.jdo.annotations.Index;
+import javax.jdo.annotations.Indices;
+import javax.jdo.annotations.PersistenceCapable;
+import javax.jdo.annotations.Queries;
+import javax.jdo.annotations.Query;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.jaxb.PersistentEntityAdapter;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.Nq;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@PersistenceCapable(
+        identityType = IdentityType.DATASTORE,
+        schema = CommandReplayResultMapping.SCHEMA,
+        table = CommandReplayResultMapping.TABLE)
+@Indices({
+        @Index(name = "CommandReplayResultMapping__recorded__IDX", members = { "recordedLogicalTypeName", "recordedIdentifier" }, unique = "true"),
+})
+@Queries({
+    @Query(
+            name = Nq.FIND,
+            value = "SELECT "
+                  + "  FROM " + CommandReplayResultMapping.FQCN + " "
+                  + " ORDER BY recordedLogicalTypeName ASC, recordedIdentifier ASC"),
+    @Query(
+            name = Nq.FIND_BY_RECORDED_BOOKMARK,
+            value = "SELECT "
+                  + "  FROM " + CommandReplayResultMapping.FQCN + " "
+                  + " WHERE recordedLogicalTypeName == :recordedLogicalTypeName "
+                  + "    && recordedIdentifier == :recordedIdentifier")
+})
+@Named(org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.LOGICAL_TYPE_NAME)
+@DomainObject(
+        editing = Editing.DISABLED,
+        entityChangePublishing = Publishing.DISABLED
+)
+@XmlJavaTypeAdapter(PersistentEntityAdapter.class)
+@NoArgsConstructor
+public class CommandReplayResultMapping
+extends org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping {
+
+    public static final String FQCN = "org.apache.causeway.extensions.commandlog.jdo.dom.CommandReplayResultMapping";
+
+    @Column(allowsNull = RecordedLogicalTypeName.ALLOWS_NULL, length = RecordedLogicalTypeName.MAX_LENGTH)
+    @RecordedLogicalTypeName
+    @Getter @Setter
+    private String recordedLogicalTypeName;
+
+    @Column(allowsNull = RecordedIdentifier.ALLOWS_NULL, length = RecordedIdentifier.MAX_LENGTH)
+    @RecordedIdentifier
+    @Getter @Setter
+    private String recordedIdentifier;
+
+    @Column(allowsNull = ActualLogicalTypeName.ALLOWS_NULL, length = ActualLogicalTypeName.MAX_LENGTH)
+    @ActualLogicalTypeName
+    @Getter @Setter
+    private String actualLogicalTypeName;
+
+    @Column(allowsNull = ActualIdentifier.ALLOWS_NULL, length = ActualIdentifier.MAX_LENGTH)
+    @ActualIdentifier
+    @Getter @Setter
+    private String actualIdentifier;
+
+}
diff --git a/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMappingRepository.java b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMappingRepository.java
new file mode 100644
index 00000000000..5484d3dd9e8
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jdo/src/main/java/org/apache/causeway/extensions/commandlog/jdo/dom/CommandReplayResultMappingRepository.java
@@ -0,0 +1,47 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.jdo.dom;
+
+import javax.inject.Named;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepositoryAbstract;
+import org.apache.causeway.extensions.commandlog.jdo.CausewayModuleExtCommandLogPersistenceJdo;
+
+/**
+ * Provides supporting functionality for querying and persisting replay result mappings.
+ */
+@Service
+@Named(CommandReplayResultMappingRepository.LOGICAL_TYPE_NAME)
+@javax.annotation.Priority(PriorityPrecedence.MIDPOINT)
+@Qualifier("Jdo")
+public class CommandReplayResultMappingRepository
+extends CommandReplayResultMappingRepositoryAbstract<CommandReplayResultMapping> {
+
+    public static final String LOGICAL_TYPE_NAME =
+            CausewayModuleExtCommandLogPersistenceJdo.NAMESPACE + ".CommandReplayResultMappingRepository";
+
+    public CommandReplayResultMappingRepository() {
+        super(CommandReplayResultMapping.class);
+    }
+
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
index 681c811a2f7..cc7c60cd1f0 100644
--- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
@@ -23,9 +23,12 @@
 import org.springframework.context.annotation.Import;
 
 import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerPersistent;
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntryPK;
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.persistence.jpa.eclipselink.CausewayModulePersistenceJpaEclipselink;
 import org.apache.causeway.testing.fixtures.applib.CausewayModuleTestingFixturesApplib;
 import org.apache.causeway.testing.fixtures.applib.fixturescripts.FixtureScript;
@@ -45,12 +48,16 @@
         // @Service's
         CommandLogEntryRepository.class,
         CommandLogEntryPK.Semantics.class,
+        CommandReplayResultMappingRepository.class,
+        CommandReplayMappingListenerPersistent.BeanFactory.class,
 
         // entities
-        CommandLogEntry.class
+        CommandLogEntry.class,
+        CommandReplayResultMapping.class
 })
 @EntityScan(basePackageClasses = {
-    CommandLogEntry.class
+    CommandLogEntry.class,
+    CommandReplayResultMapping.class
 })
 public class CausewayModuleExtCommandLogPersistenceJpa {
 
@@ -65,6 +72,7 @@ public FixtureScript teardownFixture() {
         return new TeardownFixtureJpaAbstract() {
             @Override
             protected void execute(final ExecutionContext executionContext) {
+                deleteFrom(CommandReplayResultMapping.class);
                 deleteFrom(CommandLogEntry.class);
             }
         };
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java
new file mode 100644
index 00000000000..857361881c9
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java
@@ -0,0 +1,105 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.jpa.dom;
+
+import javax.inject.Named;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EntityListeners;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Index;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.jaxb.PersistentEntityAdapter;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.Nq;
+import org.apache.causeway.persistence.jpa.applib.integration.CausewayEntityListener;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(
+        schema = CommandReplayResultMapping.SCHEMA,
+        name = CommandReplayResultMapping.TABLE,
+        indexes = {
+                @Index(name = "CommandReplayResultMapping__recorded__IDX", columnList = "recordedLogicalTypeName, recordedIdentifier")
+        },
+        uniqueConstraints = {
+                @UniqueConstraint(name = "CommandReplayResultMapping__recorded__UNQ", columnNames = { "recordedLogicalTypeName", "recordedIdentifier" })
+        }
+)
+@NamedQueries({
+    @NamedQuery(
+            name = Nq.FIND,
+            query = "SELECT m "
+                  + "  FROM CommandReplayResultMapping m "
+                  + " ORDER BY m.recordedLogicalTypeName ASC, m.recordedIdentifier ASC"),
+    @NamedQuery(
+            name = Nq.FIND_BY_RECORDED_BOOKMARK,
+            query = "SELECT m "
+                  + "  FROM CommandReplayResultMapping m "
+                  + " WHERE m.recordedLogicalTypeName = :recordedLogicalTypeName "
+                  + "   AND m.recordedIdentifier = :recordedIdentifier")
+})
+@Named(org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.LOGICAL_TYPE_NAME)
+@DomainObject(
+        editing = Editing.DISABLED,
+        entityChangePublishing = Publishing.DISABLED
+)
+@XmlJavaTypeAdapter(PersistentEntityAdapter.class)
+@EntityListeners(CausewayEntityListener.class)
+@NoArgsConstructor
+public class CommandReplayResultMapping
+extends org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping {
+
+    @Id
+    @GeneratedValue
+    @Getter @Setter
+    private Long id;
+
+    @Column(nullable = RecordedLogicalTypeName.NULLABLE, length = RecordedLogicalTypeName.MAX_LENGTH)
+    @RecordedLogicalTypeName
+    @Getter @Setter
+    private String recordedLogicalTypeName;
+
+    @Column(nullable = RecordedIdentifier.NULLABLE, length = RecordedIdentifier.MAX_LENGTH)
+    @RecordedIdentifier
+    @Getter @Setter
+    private String recordedIdentifier;
+
+    @Column(nullable = ActualLogicalTypeName.NULLABLE, length = ActualLogicalTypeName.MAX_LENGTH)
+    @ActualLogicalTypeName
+    @Getter @Setter
+    private String actualLogicalTypeName;
+
+    @Column(nullable = ActualIdentifier.NULLABLE, length = ActualIdentifier.MAX_LENGTH)
+    @ActualIdentifier
+    @Getter @Setter
+    private String actualIdentifier;
+
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.java
new file mode 100644
index 00000000000..8e69e512f54
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.java
@@ -0,0 +1,47 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you 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 org.apache.causeway.extensions.commandlog.jpa.dom;
+
+import javax.inject.Named;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepositoryAbstract;
+import org.apache.causeway.extensions.commandlog.jpa.CausewayModuleExtCommandLogPersistenceJpa;
+
+/**
+ * Provides supporting functionality for querying and persisting replay result mappings.
+ */
+@Service
+@Named(CommandReplayResultMappingRepository.LOGICAL_TYPE_NAME)
+@javax.annotation.Priority(PriorityPrecedence.MIDPOINT)
+@Qualifier("Jpa")
+public class CommandReplayResultMappingRepository
+extends CommandReplayResultMappingRepositoryAbstract<CommandReplayResultMapping> {
+
+    public static final String LOGICAL_TYPE_NAME =
+            CausewayModuleExtCommandLogPersistenceJpa.NAMESPACE + ".CommandReplayResultMappingRepository";
+
+    public CommandReplayResultMappingRepository() {
+        super(CommandReplayResultMapping.class);
+    }
+
+}
diff --git a/openspec/changes/persistent-command-replay-mapping-listener/tasks.md b/openspec/changes/persistent-command-replay-mapping-listener/tasks.md
index e6b3852fb73..0855f0e232b 100644
--- a/openspec/changes/persistent-command-replay-mapping-listener/tasks.md
+++ b/openspec/changes/persistent-command-replay-mapping-listener/tasks.md
@@ -1,45 +1,45 @@
 ## 1. Configuration and Conditional Beans
 
-- [ ] 1.1 Add a built-in replay mapping listener storage strategy enum with `IN_MEMORY` and `PERSISTENT` values.
-- [ ] 1.2 Add the storage strategy property to `CausewayConfiguration` with `IN_MEMORY` as the default.
-- [ ] 1.3 Update the existing in-memory listener bean factory so it is created only for `IN_MEMORY`, only when no custom listener exists, and still receives the conflict policy.
-- [ ] 1.4 Add tests for in-memory listener autoconfiguration, persistent-storage back-off, and custom listener back-off.
+- [x] 1.1 Add a built-in replay mapping listener storage strategy enum with `IN_MEMORY` and `PERSISTENT` values.
+- [x] 1.2 Add the storage strategy property to `CausewayConfiguration` with `IN_MEMORY` as the default.
+- [x] 1.3 Update the existing in-memory listener bean factory so it is created only for `IN_MEMORY`, only when no custom listener exists, and still receives the conflict policy.
+- [x] 1.4 Add tests for in-memory listener autoconfiguration, persistent-storage back-off, and custom listener back-off.
 
 ## 2. Applib Persistent Mapping Model
 
-- [ ] 2.1 Add an abstract applib replay result mapping entity with recorded and actual bookmark logical type and identifier properties.
-- [ ] 2.2 Add bookmark conversion helpers on the applib entity for recorded and actual bookmarks.
-- [ ] 2.3 Add an abstract applib replay result mapping repository contract for lookup by recorded bookmark, listing all mappings, and creating mappings.
-- [ ] 2.4 Add a fallback layout XML for the replay result mapping entity.
-- [ ] 2.5 Add a command log menu action that lists persisted replay result mappings through the applib repository.
+- [x] 2.1 Add an abstract applib replay result mapping entity with recorded and actual bookmark logical type and identifier properties.
+- [x] 2.2 Add bookmark conversion helpers on the applib entity for recorded and actual bookmarks.
+- [x] 2.3 Add an abstract applib replay result mapping repository contract for lookup by recorded bookmark, listing all mappings, and creating mappings.
+- [x] 2.4 Add a fallback layout XML for the replay result mapping entity.
+- [x] 2.5 Add a command log menu action that lists persisted replay result mappings through the applib repository.
 
 ## 3. Persistent Listener Implementation
 
-- [ ] 3.1 Add a persistent `CommandReplayMappingListener` implementation that depends on the applib repository and conflict policy.
-- [ ] 3.2 Implement remapping from persisted recorded bookmark mappings without resolving domain objects.
-- [ ] 3.3 Implement result mapping persistence for first non-identity mappings.
-- [ ] 3.4 Implement idempotent repeated mapping handling.
-- [ ] 3.5 Implement conflict handling that throws or logs-and-continues according to the existing conflict policy.
-- [ ] 3.6 Add conditional bean creation for the persistent listener when storage strategy is `PERSISTENT`, no custom listener exists, and a repository is available.
-- [ ] 3.7 Add tests for persistent listener remapping, recording, idempotency, strict conflicts, lenient conflicts, and conditional bean creation.
+- [x] 3.1 Add a persistent `CommandReplayMappingListener` implementation that depends on the applib repository and conflict policy.
+- [x] 3.2 Implement remapping from persisted recorded bookmark mappings without resolving domain objects.
+- [x] 3.3 Implement result mapping persistence for first non-identity mappings.
+- [x] 3.4 Implement idempotent repeated mapping handling.
+- [x] 3.5 Implement conflict handling that throws or logs-and-continues according to the existing conflict policy.
+- [x] 3.6 Add conditional bean creation for the persistent listener when storage strategy is `PERSISTENT`, no custom listener exists, and a repository is available.
+- [x] 3.7 Add tests for persistent listener remapping, recording, idempotency, strict conflicts, lenient conflicts, and conditional bean creation.
 
 ## 4. JDO Persistence Implementation
 
-- [ ] 4.1 Add a concrete JDO replay result mapping entity following the command log persistence JDO conventions.
-- [ ] 4.2 Add a concrete JDO replay result mapping repository implementing the applib repository contract.
-- [ ] 4.3 Register the JDO entity and repository with the command log JDO persistence module.
-- [ ] 4.4 Add JDO tests for create, find by recorded bookmark, list all, idempotent repeated mapping, and conflict preservation.
+- [x] 4.1 Add a concrete JDO replay result mapping entity following the command log persistence JDO conventions.
+- [x] 4.2 Add a concrete JDO replay result mapping repository implementing the applib repository contract.
+- [x] 4.3 Register the JDO entity and repository with the command log JDO persistence module.
+- [x] 4.4 Add JDO tests for create, find by recorded bookmark, list all, idempotent repeated mapping, and conflict preservation.
 
 ## 5. JPA Persistence Implementation
 
-- [ ] 5.1 Add a concrete JPA replay result mapping entity following the command log persistence JPA conventions.
-- [ ] 5.2 Add a concrete JPA replay result mapping repository implementing the applib repository contract.
-- [ ] 5.3 Register the JPA entity and repository with the command log JPA persistence module.
-- [ ] 5.4 Add JPA tests for create, find by recorded bookmark, list all, idempotent repeated mapping, and conflict preservation.
+- [x] 5.1 Add a concrete JPA replay result mapping entity following the command log persistence JPA conventions.
+- [x] 5.2 Add a concrete JPA replay result mapping repository implementing the applib repository contract.
+- [x] 5.3 Register the JPA entity and repository with the command log JPA persistence module.
+- [x] 5.4 Add JPA tests for create, find by recorded bookmark, list all, idempotent repeated mapping, and conflict preservation.
 
 ## 6. Validation
 
-- [ ] 6.1 Run targeted command log applib tests covering both built-in listener implementations and menu action behavior.
-- [ ] 6.2 Run targeted command log JDO persistence tests for the new replay result mapping entity and repository.
-- [ ] 6.3 Run targeted command log JPA persistence tests for the new replay result mapping entity and repository.
-- [ ] 6.4 Run `openspec validate persistent-command-replay-mapping-listener --type change --strict`.
+- [x] 6.1 Run targeted command log applib tests covering both built-in listener implementations and menu action behavior.
+- [x] 6.2 Run targeted command log JDO persistence tests for the new replay result mapping entity and repository.
+- [x] 6.3 Run targeted command log JPA persistence tests for the new replay result mapping entity and repository.
+- [x] 6.4 Run `openspec validate persistent-command-replay-mapping-listener --type change --strict`.