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/08/06 08:06:57 UTC

(causeway) 08/15: CAUSEWAY-3910: reconcile replayable command projection

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

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

commit 576f7b49a5f0edc9ebeea7742ff2e86f9eb2337e
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 06:47:53 2026 +0100

    CAUSEWAY-3910: reconcile replayable command projection
---
 .../adoc/modules/commandlog/pages/about.adoc       |  26 +++
 .../applib/CausewayModuleExtCommandLogApplib.java  |  21 +-
 .../commandlog/applib/dom/CommandLogEntry.java     |   2 +
 .../applib/dom/CommandLogEntryRepository.java      |   8 +
 .../dom/CommandLogEntryRepositoryAbstract.java     |  24 ++
 .../applib/dom/replay/CommandExportManager.java    |   4 +
 .../applib/dom/replay/CommandReplayManager.java    |   2 +
 .../applib/dom/replay/ReplayContext.java           |  18 +-
 .../ReplayableCommand.columnOrder.fallback.txt     |   5 +-
 .../applib/dom/replay/ReplayableCommand.java       | 184 ++++++++++++---
 .../replay/ReplayableCommand.layout.fallback.xml   |  59 ++---
 .../dom/replay/ReplayableCommandEligibility.java   |  71 ++++++
 ...ableCommandParticipant.columnOrder.fallback.txt |   8 +
 .../dom/replay/ReplayableCommandParticipant.java   | 250 +++++++++++++++++++++
 ...eplayableCommandParticipant.layout.fallback.xml |  38 ++++
 .../applib/dom/replay/ReplayableCommand_next.java  |  48 ++++
 .../dom/replay/ReplayableCommand_previous.java     |  48 ++++
 ...CommandLogEntryRepositoryAdjacentQueryTest.java |  84 +++++++
 .../dom/replay/CommandManagerEligibilityTest.java  | 128 +++++++++++
 .../replay/ReplayableCommandEligibilityTest.java   |  88 ++++++++
 .../replay/ReplayableCommandNavigationTest.java    | 145 ++++++++++++
 .../replay/ReplayableCommandParticipantTest.java   | 247 ++++++++++++++++++++
 .../replay/ReplayableCommandPresentationTest.java  |  92 ++++++++
 .../commandlog/jpa/dom/CommandLogEntry.java        |  14 ++
 .../CommandLogEntryAdjacentQuery_IntegTest.java    |  89 ++++++++
 .../tasks.md                                       |  42 ++--
 26 files changed, 1638 insertions(+), 107 deletions(-)

diff --git a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
index 2048d3672f9..9e037a45b17 100644
--- a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
+++ b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
@@ -132,6 +132,32 @@ When the repository is available, the commandlog menu provides prototyping actio
 Each mapping can also be inspected and deleted individually.
 An application-defined `CommandReplayMappingListener` bean remains authoritative and causes both built-in listeners to back off.
 
+=== Replayable Command Projections
+
+The export and replay managers wrap command-log entries as `ReplayableCommand` view models.
+Each command reports whether it has a recorded result and derives a Participants collection from its command data:
+
+* `TARGET` rows represent recorded target bookmarks;
+* `PARAMETER` rows represent reference-valued action parameters and include the parameter name (scalar parameters are omitted); and
+* `RESULT` represents the recorded result bookmark, when present.
+
+Participants show the recorded bookmark even when the referenced object is unavailable locally.
+Their actual bookmark comes from the configured replay-mapping service.
+For target and parameter rows an explicit mapping is visible in any replay state; after a successful (`OK`) replay, an unmapped bookmark falls back to the recorded value.
+A result row exposes its actual bookmark only after successful replay, using an explicit mapping when available and otherwise the recorded value.
+The optional target, argument, and result object links resolve only the actual bookmark and remain empty if that object is not available locally; bookmark audit data remains visible.
+
+Participant view-model mementos contain identity rather than bookmark state: the owning interaction id followed by `--target`, `--parameter--<name>`, or `--result`.
+Opening such a memento re-derives the participant from the current command and current replay mappings.
+
+General command projections include state-changing commands, safe commands with a recorded result, and commands whose semantics cannot be resolved.
+They omit resultless safe actions without deleting or changing the underlying command-log entries.
+The pending-or-failed replay collection is intentionally exempt so that all imported replay work remains reviewable.
+The safe Previous and Next actions navigate foreground commands in command-log order, skipping entries that are ineligible under this general rule.
+
+For now, export and replay continue to use the separate `CommandExportManager` and `CommandReplayManager` view models.
+A future change may unify their sequence-management surface; the participant projection and eligibility policy do not depend on that redesign.
+
 
 [#menubar-layout-xml]
 === menubar.layout.xml
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
index ef234f03384..5b9d3649ed2 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/CausewayModuleExtCommandLogApplib.java
@@ -22,6 +22,8 @@
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
 import org.apache.causeway.applib.services.clock.ClockService;
 import org.apache.causeway.applib.services.command.CommandExecutorService;
 import org.apache.causeway.applib.services.iactn.InteractionService;
@@ -43,14 +45,15 @@
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_delete;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ResultRemappingService;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_excludeFromReplay;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_makeExportable;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_next;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openCommandLogEntry;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openTarget;
-import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_openTargetTR;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_previous;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommand_replayOrRetry;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ResultRemappingService;
 import org.apache.causeway.extensions.commandlog.applib.fakescheduler.FakeScheduler;
 import org.apache.causeway.extensions.commandlog.applib.job.BackgroundCommandsJobControl;
 import org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob;
@@ -67,6 +70,7 @@
 
         // viewmodels
         CommandReplayManager.class,
+        ReplayableCommandParticipant.class,
 
         // mixins
         HasInteractionId_commandLogEntry.class,
@@ -78,8 +82,8 @@
         CommandReplayResultMapping_delete.class,
         ReplayableCommand_makeExportable.class,
         ReplayableCommand_openCommandLogEntry.class,
-        ReplayableCommand_openTarget.class,
-        ReplayableCommand_openTargetTR.class,
+        ReplayableCommand_previous.class,
+        ReplayableCommand_next.class,
         ReplayableCommand_replayOrRetry.class,
         ReplayableCommand_excludeFromReplay.class,
         ReplayableCommand_delete.class,
@@ -165,9 +169,12 @@ public static void honorSystemEnvironment() {
             final CommandLogEntryRepository commandLogEntryRepository,
             final CommandExecutorService commandExecutorService,
             final ClockService clockService,
-            final ResultRemappingService resultRemappingService) {
+            final ResultRemappingService resultRemappingService,
+            final BookmarkService bookmarkService,
+            final ApplicationFeatureRepository applicationFeatureRepository) {
         return new ReplayContext(repositoryService, interactionService, transactionService,
-                commandLogEntryRepository, commandExecutorService, clockService, resultRemappingService);
+                commandLogEntryRepository, commandExecutorService, clockService, resultRemappingService,
+                bookmarkService, applicationFeatureRepository);
     }
 
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
index 3a1cf5d632b..bab9b2908ea 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntry.java
@@ -145,6 +145,8 @@ public static class Nq {
          * primary before the production database was restored to the secondary.
          */
         public static final String FIND_MOST_RECENT_COMPLETED           = LOGICAL_TYPE_NAME + ".findMostRecentCompleted";
+        public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER   = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfter";
+        public static final String FIND_FOREGROUND_BY_TIMESTAMP_BEFORE  = LOGICAL_TYPE_NAME + ".findForegroundByTimestampBefore";
         public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE  = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayState";
         public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATES = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndReplayStates";
 
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
index 3cc377148c1..0864421465f 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepository.java
@@ -121,6 +121,14 @@ List<CommandLogEntry> findByTargetAndFromAndTo(
      */
     List<CommandLogEntry> findSince(UUID interactionId, Integer batchSize);
 
+    default List<CommandLogEntry> findForegroundSinceTimestamp(final Timestamp since) {
+        return findForegroundSinceTimestamp(since, null);
+    }
+
+    List<CommandLogEntry> findForegroundSinceTimestamp(Timestamp since, @Nullable Integer limitIfAny);
+
+    List<CommandLogEntry> findForegroundBeforeTimestamp(Timestamp before, @Nullable Integer limitIfAny);
+
     List<CommandLogEntry> findForegroundSinceTimestampAndCanBeExported(Timestamp since);
 
     List<CommandLogEntry> findForegroundSinceTimestampAndHasBeenExported(Timestamp since);
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
index d4656396785..9a0a03c7496 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAbstract.java
@@ -394,6 +394,30 @@ private List<CommandLogEntry> findSince(
                 : commandJdos;
     }
 
+    @Override
+    public List<CommandLogEntry> findForegroundSinceTimestamp(
+            final Timestamp since,
+            final @Nullable Integer limitIfAny) {
+        var query = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER)
+                .withParameter("from", since);
+        if (limitIfAny != null) {
+            query = query.withRange(QueryRange.limit(limitIfAny));
+        }
+        return _Casts.uncheckedCast(repositoryService().allMatches(query));
+    }
+
+    @Override
+    public List<CommandLogEntry> findForegroundBeforeTimestamp(
+            final Timestamp before,
+            final @Nullable Integer limitIfAny) {
+        var query = Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE)
+                .withParameter("to", before);
+        if (limitIfAny != null) {
+            query = query.withRange(QueryRange.limit(limitIfAny));
+        }
+        return _Casts.uncheckedCast(repositoryService().allMatches(query));
+    }
+
     @Override
     public List<CommandLogEntry> findForegroundSinceTimestampAndCanBeExported(final Timestamp since) {
         return findForegroundSinceTimestampWithState(since, ReplayState.UNDEFINED);
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
index bfe33fc6c0b..b1402719ab8 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager.java
@@ -142,6 +142,8 @@ public class DomainEvent extends ActionDomainEvent<nextHour> { }
             describedAs = "Commands that can be exported")
     public List<ReplayableCommand> getNotYetExported() {
         return commandLogEntryRepository().findForegroundSinceTimestampAndCanBeExported(baseline).stream()
+            .filter(entry -> ReplayableCommandEligibility.isEligible(
+                    entry, replayContext.applicationFeatureRepository()))
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
@@ -233,6 +235,8 @@ public class DomainEvent extends ActionDomainEvent<exportSelected> { }
     @CollectionLayout(describedAs = "Commands that have been exported")
     public List<ReplayableCommand> getExported() {
         return commandLogEntryRepository().findForegroundSinceTimestampAndHasBeenExported(baseline).stream()
+            .filter(entry -> ReplayableCommandEligibility.isEligible(
+                    entry, replayContext.applicationFeatureRepository()))
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
index d48d19a0522..7fee1988904 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
@@ -320,6 +320,8 @@ public class DomainEvent extends ActionDomainEvent<deleteSelectedPendingOrFailed
                     + "or marked to be excluded from replay (replayState=EXCLUDE)")
     public List<ReplayableCommand> getSucceededOrExcluded() {
         return commandLogEntryRepository().findSinceAndWithReplayOkOrExcluded(baseline).stream()
+            .filter(entry -> ReplayableCommandEligibility.isEligible(
+                    entry, replayContext.applicationFeatureRepository()))
             .map(entry->new ReplayableCommand(
                     entry.getInteractionId(),
                     replayContext))
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
index 897b614f30e..d1978c1ea13 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayContext.java
@@ -23,6 +23,8 @@
 
 import org.jspecify.annotations.Nullable;
 
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
 import org.apache.causeway.applib.services.clock.ClockService;
 import org.apache.causeway.applib.services.command.CommandExecutorService;
 import org.apache.causeway.applib.services.iactn.InteractionService;
@@ -41,7 +43,21 @@ public record ReplayContext(
         CommandLogEntryRepository commandLogEntryRepository,
         CommandExecutorService commandExecutorService,
         ClockService clockService,
-        ResultRemappingService resultRemappingService) {
+        ResultRemappingService resultRemappingService,
+        @Nullable BookmarkService bookmarkService,
+        @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+
+    public ReplayContext(
+            final RepositoryService repositoryService,
+            final InteractionService interactionService,
+            final TransactionService transactionService,
+            final CommandLogEntryRepository commandLogEntryRepository,
+            final CommandExecutorService commandExecutorService,
+            final ClockService clockService,
+            final ResultRemappingService resultRemappingService) {
+        this(repositoryService, interactionService, transactionService, commandLogEntryRepository,
+                commandExecutorService, clockService, resultRemappingService, null, null);
+    }
 
     public Optional<CommandLogEntry> lookupCommandLogEntry(final @Nullable UUID interactionId) {
         return interactionId!=null
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
index bde91a6f91d..1b87450bbbc 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.columnOrder.fallback.txt
@@ -1,6 +1,5 @@
 #interactionId
 timestamp
-targetType
-targetId
 member
-replayState
\ No newline at end of file
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
index ea291deadd2..9d7edb611bb 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.java
@@ -18,10 +18,13 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
+import java.sql.Timestamp;
 import java.time.Instant;
 import java.time.ZonedDateTime;
 import java.time.chrono.ChronoZonedDateTime;
+import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.List;
 import java.util.Optional;
 import java.util.UUID;
 
@@ -31,23 +34,20 @@
 import org.springframework.transaction.annotation.Propagation;
 
 import org.apache.causeway.applib.ViewModel;
-import org.apache.causeway.applib.annotation.Action;
-import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.Collection;
+import org.apache.causeway.applib.annotation.CollectionLayout;
 import org.apache.causeway.applib.annotation.DomainObject;
 import org.apache.causeway.applib.annotation.DomainObjectLayout;
 import org.apache.causeway.applib.annotation.Introspection;
 import org.apache.causeway.applib.annotation.LabelPosition;
-import org.apache.causeway.applib.annotation.MemberSupport;
 import org.apache.causeway.applib.annotation.ObjectSupport;
 import org.apache.causeway.applib.annotation.Programmatic;
 import org.apache.causeway.applib.annotation.Property;
 import org.apache.causeway.applib.annotation.PropertyLayout;
-import org.apache.causeway.applib.annotation.SemanticsOf;
 import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.fa.FontAwesomeLayers;
 import org.apache.causeway.applib.jaxb.JavaTimeXMLGregorianCalendarMarshalling;
 import org.apache.causeway.applib.services.bookmark.Bookmark;
-import org.apache.causeway.applib.services.bookmark.BookmarkService;
 import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
 import org.apache.causeway.applib.services.command.CommandExecutorService.InteractionContextPolicy;
 import org.apache.causeway.commons.functional.Try;
@@ -59,9 +59,12 @@
 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.ReplayState;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant.Role;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
 import org.apache.causeway.schema.cmd.v2.CommandDto;
 import org.apache.causeway.schema.cmd.v2.MemberDto;
 import org.apache.causeway.schema.common.v2.OidDto;
+import org.apache.causeway.schema.common.v2.ValueType;
 import org.apache.causeway.valuetypes.asciidoc.applib.value.AsciiDoc;
 import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocBuilder;
 import org.apache.causeway.valuetypes.asciidoc.builder.AsciiDocFactory;
@@ -173,11 +176,7 @@ public Optional<ZonedDateTime> getTimestampIfAny() {
                 .map(JavaTimeXMLGregorianCalendarMarshalling::toZonedDateTime);
     }
 
-    @Property
-    @PropertyLayout(
-            sequence = "2.1",
-            fieldSetId = "details",
-            describedAs = "Target Type of the original (replayable) Command")
+    @Programmatic
     public String getTargetType() {
         return commandRecord()
             .map(CommandRecord::commandDto)
@@ -186,11 +185,7 @@ public String getTargetType() {
             .orElse(null);
     }
 
-    @Property
-    @PropertyLayout(
-            sequence = "2.2",
-            fieldSetId = "details",
-            describedAs = "Target ID of the original (replayable) Command")
+    @Programmatic
     public String getTargetId() {
         return commandRecord()
             .map(CommandRecord::commandDto)
@@ -200,26 +195,6 @@ public String getTargetId() {
             .orElse(null);
     }
 
-    @Action(semantics = SemanticsOf.SAFE)
-    @ActionLayout(
-            cssClassFa = "fa-bullseye"
-    )
-    public class openTarget {
-        @Inject BookmarkService bookmarkService;
-
-        @MemberSupport public Object act() {
-            return commandLogEntry()
-                .map(CommandLogEntry::getTarget)
-                .flatMap(bookmark -> bookmarkService.lookup(bookmark))
-                .orElse(null);
-        }
-        @MemberSupport public String disableAct() {
-            return commandLogEntry().isPresent()
-                ? null
-                : "Unknown target";
-        }
-    }
-
     @Property
     @PropertyLayout(
             sequence = "3.1",
@@ -249,6 +224,109 @@ public ReplayState getReplayState() {
             .orElse(null);
     }
 
+    @Property
+    @PropertyLayout(
+            sequence = "5",
+            fieldSetId = "details",
+            describedAs = "Whether this command stores a recorded result bookmark")
+    public boolean getHasResult() {
+        return commandLogEntry()
+                .map(CommandLogEntry::getResult)
+                .isPresent();
+    }
+
+    @Collection
+    @CollectionLayout(sequence = "6", named = "Participants")
+    public List<ReplayableCommandParticipant> getParticipants() {
+        return commandLogEntry()
+                .map(this::participantsFor)
+                .orElseGet(List::of);
+    }
+
+    private List<ReplayableCommandParticipant> participantsFor(final CommandLogEntry commandLogEntry) {
+        var participants = new ArrayList<ReplayableCommandParticipant>();
+        var commandDto = commandLogEntry.getCommandDto();
+        addTargetParticipants(participants, commandLogEntry, commandDto);
+        addReferenceParameterParticipants(participants, commandLogEntry, commandDto);
+        addResultParticipant(participants, commandLogEntry);
+        return participants;
+    }
+
+    private void addTargetParticipants(
+            final List<ReplayableCommandParticipant> participants,
+            final CommandLogEntry commandLogEntry,
+            final CommandDto commandDto) {
+        Optional.ofNullable(commandDto)
+                .map(CommandDto::getTargets)
+                .stream()
+                .flatMap(targets -> targets.getOid().stream())
+                .filter(java.util.Objects::nonNull)
+                .map(Bookmark::forOidDto)
+                .forEach(bookmark -> participants.add(participant(
+                        commandLogEntry, Role.TARGET, null, bookmark)));
+    }
+
+    private void addReferenceParameterParticipants(
+            final List<ReplayableCommandParticipant> participants,
+            final CommandLogEntry commandLogEntry,
+            final CommandDto commandDto) {
+        if (commandDto == null || !(commandDto.getMember() instanceof ActionDto actionDto)) {
+            return;
+        }
+        Optional.ofNullable(actionDto.getParameters())
+                .stream()
+                .flatMap(parameters -> parameters.getParameter().stream())
+                .filter(parameter -> parameter != null
+                        && parameter.getType() == ValueType.REFERENCE
+                        && parameter.getReference() != null)
+                .forEach(parameter -> participants.add(participant(
+                        commandLogEntry,
+                        Role.PARAMETER,
+                        parameter.getName(),
+                        Bookmark.forOidDto(parameter.getReference()))));
+    }
+
+    private void addResultParticipant(
+            final List<ReplayableCommandParticipant> participants,
+            final CommandLogEntry commandLogEntry) {
+        if (commandLogEntry.getResult() != null) {
+            participants.add(participant(
+                    commandLogEntry, Role.RESULT, null, commandLogEntry.getResult()));
+        }
+    }
+
+    private ReplayableCommandParticipant participant(
+            final CommandLogEntry commandLogEntry,
+            final Role role,
+            final String parameterName,
+            final Bookmark recordedBookmark) {
+        var owningInteractionId = commandLogEntry.getInteractionId() != null
+                ? commandLogEntry.getInteractionId()
+                : interactionId;
+        var actualBookmark = actualBookmarkFor(
+                role, recordedBookmark, commandLogEntry.getReplayState()).orElse(null);
+        return new ReplayableCommandParticipant(
+                owningInteractionId,
+                role,
+                parameterName,
+                recordedBookmark,
+                actualBookmark,
+                replayContext);
+    }
+
+    private Optional<Bookmark> actualBookmarkFor(
+            final Role role,
+            final Bookmark recordedBookmark,
+            final ReplayState replayState) {
+        if (role == Role.RESULT && replayState != ReplayState.OK) {
+            return Optional.empty();
+        }
+        return replayContext.resultRemappingService().lookup(recordedBookmark)
+                .or(() -> replayState == ReplayState.OK
+                        ? Optional.of(recordedBookmark)
+                        : Optional.empty());
+    }
+
     @Property
     @PropertyLayout(
             sequence = "9",
@@ -269,6 +347,40 @@ public AsciiDoc getDto() {
 
     // -- ACTIONS
 
+    ReplayableCommand previous() {
+        return adjacent(false).orElse(this);
+    }
+
+    String disablePrevious() {
+        return adjacent(false).isEmpty() ? "No previous replayable command" : null;
+    }
+
+    ReplayableCommand next() {
+        return adjacent(true).orElse(this);
+    }
+
+    String disableNext() {
+        return adjacent(true).isEmpty() ? "No next replayable command" : null;
+    }
+
+    private Optional<ReplayableCommand> adjacent(final boolean forward) {
+        return getTimestampIfAny()
+                .map(ChronoZonedDateTime::toInstant)
+                .map(Timestamp::from)
+                .stream()
+                .flatMap(timestamp -> (forward
+                        ? replayContext.commandLogEntryRepository()
+                                .findForegroundSinceTimestamp(timestamp)
+                        : replayContext.commandLogEntryRepository()
+                                .findForegroundBeforeTimestamp(timestamp, null))
+                        .stream())
+                .filter(entry -> !interactionId.equals(entry.getInteractionId()))
+                .filter(entry -> ReplayableCommandEligibility.isEligible(
+                        entry, replayContext.applicationFeatureRepository()))
+                .findFirst()
+                .map(entry -> new ReplayableCommand(entry.getInteractionId(), replayContext));
+    }
+
 
     ReplayableCommand makeExportable() {
         if(disableMakeExportable()!=null)
@@ -431,4 +543,4 @@ private void onReplaySuccess(final Bookmark actualResult) {
             });
     }
 
-}
\ No newline at end of file
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
index 4b3f8a8658d..e03489ab878 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand.layout.fallback.xml
@@ -1,50 +1,18 @@
 <?xml version="1.0" encoding="UTF-8"?><bs:grid xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" xmlns:lnk="https://causeway.apache.org/applib/layout/links" xmlns:cpt="https://causeway.apache.org/applib/layout/component" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/links https://caus [...]
     <bs:row>
-        <bs:col span="12" unreferencedActions="true">
+        <bs:col span="12">
             <cpt:domainObject/>
         </bs:col>
     </bs:row>
     <bs:row>
         <bs:col span="6">
-            <bs:tabGroup>
-                <bs:tab name="Identity">
-                    <bs:row>
-                        <bs:col span="12">
-                            <cpt:fieldSet name="Id" id="id">
-                                <cpt:action id="delete" position="PANEL"/>
-                                <cpt:property id="interactionId"/>
-                                <cpt:property id="timestamp"/>
-                            </cpt:fieldSet>
-                        </bs:col>
-                    </bs:row>
-                </bs:tab>
-                <bs:tab name="Other">
-                    <bs:row>
-                        <bs:col span="12">
-                            <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
-                        </bs:col>
-                    </bs:row>
-                </bs:tab>
-                <bs:tab name="Metadata">
-                    <bs:row>
-                        <bs:col span="12">
-                            <cpt:fieldSet name="Metadata" id="metadata">
-                                <cpt:property id="logicalTypeName"/>
-                                <cpt:property id="interactionId"/>
-                            </cpt:fieldSet>
-                        </bs:col>
-                    </bs:row>
-                </bs:tab>
-            </bs:tabGroup>
-            <cpt:fieldSet name="Target" id="target">
-                <cpt:action id="openTarget" position="PANEL"/>
-                <cpt:property id="targetType"/>
-                <cpt:property id="targetId"/>
+            <cpt:fieldSet name="Details" id="details">
+                <cpt:action id="previous" position="PANEL"/>
+                <cpt:action id="next" position="PANEL"/>
+                <cpt:property id="interactionId"/>
+                <cpt:property id="timestamp"/>
                 <cpt:property id="member"/>
-            </cpt:fieldSet>
-            <cpt:fieldSet name="Control" id="control">
-                <cpt:action id="replayOrRetry" position="PANEL"/>
-                <cpt:property id="replayState"/>
+                <cpt:property id="hasResult"/>
             </cpt:fieldSet>
         </bs:col>
         <bs:col span="6">
@@ -54,6 +22,19 @@
             </cpt:fieldSet>
         </bs:col>
     </bs:row>
+    <bs:row>
+        <bs:col span="12">
+            <cpt:collection id="participants"/>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="12" unreferencedActions="true">
+            <cpt:fieldSet name="Control" id="control" unreferencedProperties="true">
+                <cpt:action id="replayOrRetry" position="PANEL"/>
+                <cpt:property id="replayState"/>
+            </cpt:fieldSet>
+        </bs:col>
+    </bs:row>
     <bs:row>
         <bs:col span="12">
             <bs:tabGroup unreferencedCollections="true"/>
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java
new file mode 100644
index 00000000000..28c827f3261
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java
@@ -0,0 +1,71 @@
+/*
+ *  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.replay;
+
+import java.util.Optional;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureId;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Determines whether a command-log entry is useful in general replayable-command projections.
+ *
+ * @since 4.0 {@index}
+ */
+@UtilityClass
+public class ReplayableCommandEligibility {
+
+    public boolean isEligible(
+            final @Nullable CommandLogEntry entry,
+            final @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+        if (entry == null) {
+            return false;
+        }
+        return !isSafeAction(entry, applicationFeatureRepository)
+                || entry.getResult() != null;
+    }
+
+    private boolean isSafeAction(
+            final CommandLogEntry entry,
+            final @Nullable ApplicationFeatureRepository applicationFeatureRepository) {
+        if (applicationFeatureRepository == null
+                || entry.getCommandDto() == null
+                || !(entry.getCommandDto().getMember() instanceof ActionDto)
+                || entry.getLogicalMemberIdentifier() == null) {
+            return false;
+        }
+        try {
+            return Optional.of(ApplicationFeatureId.newMember(entry.getLogicalMemberIdentifier()))
+                    .map(applicationFeatureRepository::findFeature)
+                    .flatMap(feature -> feature != null
+                            ? feature.getActionSemantics()
+                            : Optional.empty())
+                    .map(semantics -> semantics.isSafeInNature())
+                    .orElse(false);
+        } catch (RuntimeException ex) {
+            return false;
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt
new file mode 100644
index 00000000000..27c74899e1d
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt
@@ -0,0 +1,8 @@
+#replayableCommand
+role
+parameterName
+#recordedBookmark
+target
+argument
+result
+#actualBookmark
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java
new file mode 100644
index 00000000000..64e8e9ff912
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java
@@ -0,0 +1,250 @@
+/*
+ *  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.replay;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.ViewModel;
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Introspection;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.annotation.Optionality;
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.Where;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
+import org.apache.causeway.applib.util.TitleBuffer;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+/**
+ * Derived view-model row describing one bookmark-bearing participant in a replayable command.
+ *
+ * @since 4.0 {@index}
+ */
+@Named(ReplayableCommandParticipant.LOGICAL_TYPE_NAME)
+@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED)
+@DomainObjectLayout
+public final class ReplayableCommandParticipant
+implements ViewModel, CommandRecordingSuppressed {
+
+    public static final String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE
+            + ".ReplayableCommandParticipant";
+
+    public enum Role {
+        TARGET,
+        PARAMETER,
+        RESULT
+    }
+
+    private final @Nullable UUID owningInteractionId;
+    private final @Nullable Role role;
+    private final @Nullable String parameterName;
+    private final @Nullable Bookmark recordedBookmark;
+    private final @Nullable Bookmark actualBookmark;
+    private final ReplayContext replayContext;
+
+    ReplayableCommandParticipant(
+            final UUID owningInteractionId,
+            final Role role,
+            final @Nullable String parameterName,
+            final Bookmark recordedBookmark,
+            final @Nullable Bookmark actualBookmark,
+            final ReplayContext replayContext) {
+        this.owningInteractionId = owningInteractionId;
+        this.role = role;
+        this.parameterName = parameterName;
+        this.recordedBookmark = recordedBookmark;
+        this.actualBookmark = actualBookmark;
+        this.replayContext = replayContext;
+    }
+
+    @Inject
+    public ReplayableCommandParticipant(
+            final String memento,
+            final ReplayContext replayContext) {
+        var parsed = Memento.parse(memento);
+        var derived = parsed.participantFrom(replayContext);
+        this.owningInteractionId = parsed.owningInteractionId;
+        this.role = parsed.role;
+        this.parameterName = parsed.parameterName;
+        this.recordedBookmark = derived
+                .map(ReplayableCommandParticipant::getRecordedBookmark)
+                .orElse(null);
+        this.actualBookmark = derived
+                .map(ReplayableCommandParticipant::getActualBookmark)
+                .orElse(null);
+        this.replayContext = replayContext;
+    }
+
+    @ObjectSupport
+    public String title() {
+        var title = new TitleBuffer()
+                .append("Replay participant")
+                .append(" - ")
+                .append(getRole());
+        if (parameterName != null) {
+            title.append(" ").append(parameterName);
+        }
+        return title
+                .append(" ")
+                .append(getRecordedBookmark())
+                .append(" → ")
+                .append(getActualBookmark())
+                .toString();
+    }
+
+    @Override
+    public String viewModelMemento() {
+        if (owningInteractionId == null || role == null) {
+            return "";
+        }
+        return switch (role) {
+            case TARGET -> owningInteractionId + "--target";
+            case PARAMETER -> owningInteractionId + "--parameter--"
+                    + (parameterName != null ? parameterName : "");
+            case RESULT -> owningInteractionId + "--result";
+        };
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "0")
+    public @Nullable ReplayableCommand getReplayableCommand() {
+        return owningInteractionId != null
+                ? new ReplayableCommand(owningInteractionId, replayContext)
+                : null;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "0.1", hidden = Where.OBJECT_FORMS)
+    public @Nullable UUID getOwningInteractionId() {
+        return owningInteractionId;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "1")
+    public @Nullable Role getRole() {
+        return role;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "2")
+    public @Nullable String getParameterName() {
+        return parameterName;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "3")
+    public @Nullable Bookmark getRecordedBookmark() {
+        return recordedBookmark;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "4")
+    public @Nullable Object getTarget() {
+        return role == Role.TARGET ? lookupActualObject().orElse(null) : null;
+    }
+
+    @MemberSupport
+    public boolean hideTarget() {
+        return role != Role.TARGET;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "5")
+    public @Nullable Object getArgument() {
+        return role == Role.PARAMETER ? lookupActualObject().orElse(null) : null;
+    }
+
+    @MemberSupport
+    public boolean hideArgument() {
+        return role != Role.PARAMETER;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "6")
+    public @Nullable Object getResult() {
+        return role == Role.RESULT ? lookupActualObject().orElse(null) : null;
+    }
+
+    @MemberSupport
+    public boolean hideResult() {
+        return role != Role.RESULT;
+    }
+
+    @Property(optionality = Optionality.OPTIONAL)
+    @PropertyLayout(sequence = "7")
+    public @Nullable Bookmark getActualBookmark() {
+        return actualBookmark;
+    }
+
+    @Programmatic
+    Optional<Object> lookupActualObject() {
+        return actualBookmark != null && replayContext.bookmarkService() != null
+                ? replayContext.bookmarkService().lookup(actualBookmark)
+                : Optional.empty();
+    }
+
+    private record Memento(
+            @Nullable UUID owningInteractionId,
+            @Nullable Role role,
+            @Nullable String parameterName) {
+
+        private static Memento parse(final String memento) {
+            var parts = memento.split("--", 3);
+            var interactionId = parts.length > 0 && !parts[0].isEmpty()
+                    ? UUID.fromString(parts[0])
+                    : null;
+            var role = parts.length > 1 && !parts[1].isEmpty()
+                    ? Role.valueOf(parts[1].toUpperCase())
+                    : null;
+            var parameterName = role == Role.PARAMETER && parts.length > 2 && !parts[2].isEmpty()
+                    ? parts[2]
+                    : null;
+            return new Memento(interactionId, role, parameterName);
+        }
+
+        private Optional<ReplayableCommandParticipant> participantFrom(final ReplayContext replayContext) {
+            if (owningInteractionId == null || role == null) {
+                return Optional.empty();
+            }
+            return new ReplayableCommand(owningInteractionId, replayContext)
+                    .getParticipants()
+                    .stream()
+                    .filter(this::matches)
+                    .findFirst();
+        }
+
+        private boolean matches(final ReplayableCommandParticipant participant) {
+            return participant.getRole() == role
+                    && (role != Role.PARAMETER
+                        || Objects.equals(participant.getParameterName(), parameterName));
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml
new file mode 100644
index 00000000000..ac799dd5692
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?><bs:grid xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" xmlns:lnk="https://causeway.apache.org/applib/layout/links" xmlns:cpt="https://causeway.apache.org/applib/layout/component" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/links https://caus [...]
+    <bs:row>
+        <bs:col span="12" unreferencedActions="true">
+            <cpt:domainObject/>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="4">
+            <cpt:fieldSet name="General" id="general">
+                <cpt:property id="replayableCommand"/>
+                <cpt:property id="role"/>
+                <cpt:property id="parameterName"/>
+            </cpt:fieldSet>
+            <cpt:fieldSet name="Metadata" id="metadata">
+                <cpt:property id="logicalTypeName"/>
+            </cpt:fieldSet>
+            <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
+        </bs:col>
+        <bs:col span="4">
+            <cpt:fieldSet name="Recorded" id="recorded">
+                <cpt:property id="recordedBookmark"/>
+                <cpt:property id="target"/>
+                <cpt:property id="argument"/>
+            </cpt:fieldSet>
+        </bs:col>
+        <bs:col span="4">
+            <cpt:fieldSet name="Actual" id="actual">
+                <cpt:property id="actualBookmark"/>
+                <cpt:property id="result"/>
+            </cpt:fieldSet>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="12">
+            <bs:tabGroup unreferencedCollections="true"/>
+        </bs:col>
+    </bs:row>
+</bs:grid>
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java
new file mode 100644
index 00000000000..e8367da7def
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java
@@ -0,0 +1,48 @@
+/*
+ *  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.replay;
+
+import lombok.RequiredArgsConstructor;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Action(
+        semantics = SemanticsOf.SAFE,
+        commandPublishing = Publishing.DISABLED,
+        executionPublishing = Publishing.DISABLED)
+@ActionLayout(sequence = "0.3", describedAs = "Opens the next replayable foreground command")
+@RequiredArgsConstructor
+public class ReplayableCommand_next {
+
+    private final ReplayableCommand replayableCommand;
+
+    @MemberSupport
+    public ReplayableCommand act() {
+        return replayableCommand.next();
+    }
+
+    @MemberSupport
+    public String disableAct() {
+        return replayableCommand.disableNext();
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java
new file mode 100644
index 00000000000..b35d40e455c
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java
@@ -0,0 +1,48 @@
+/*
+ *  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.replay;
+
+import lombok.RequiredArgsConstructor;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Action(
+        semantics = SemanticsOf.SAFE,
+        commandPublishing = Publishing.DISABLED,
+        executionPublishing = Publishing.DISABLED)
+@ActionLayout(sequence = "0.2", describedAs = "Opens the previous replayable foreground command")
+@RequiredArgsConstructor
+public class ReplayableCommand_previous {
+
+    private final ReplayableCommand replayableCommand;
+
+    @MemberSupport
+    public ReplayableCommand act() {
+        return replayableCommand.previous();
+    }
+
+    @MemberSupport
+    public String disableAct() {
+        return replayableCommand.disablePrevious();
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java
new file mode 100644
index 00000000000..a13dc369787
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java
@@ -0,0 +1,84 @@
+/*
+ *  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.sql.Timestamp;
+import java.util.List;
+
+import jakarta.inject.Provider;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.apache.causeway.applib.query.NamedQuery;
+import org.apache.causeway.applib.query.Query;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CommandLogEntryRepositoryAdjacentQueryTest {
+
+    private final RepositoryService repositoryService = mock(RepositoryService.class);
+    private final Repository repository = new Repository();
+
+    @BeforeEach
+    void setUp() {
+        repository.repositoryServiceProvider = providerOf(repositoryService);
+        when(repositoryService.allMatches(any(Query.class))).thenReturn(List.of());
+    }
+
+    @Test
+    void selectsOrderedForegroundQueriesAndOptionalLimit() {
+        var timestamp = Timestamp.valueOf("2026-08-05 12:00:00");
+
+        repository.findForegroundSinceTimestamp(timestamp, 7);
+        var after = captureLastQuery();
+        assertThat(after.getName()).isEqualTo(CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER);
+        assertThat(after.getParametersByName()).containsEntry("from", timestamp);
+        assertThat(after.getRange().getLimit()).isEqualTo(7);
+
+        repository.findForegroundBeforeTimestamp(timestamp, null);
+        var before = captureLastQuery();
+        assertThat(before.getName()).isEqualTo(CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE);
+        assertThat(before.getParametersByName()).containsEntry("to", timestamp);
+        assertThat(before.getRange().isUnconstrained()).isTrue();
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private NamedQuery<CommandLogEntry> captureLastQuery() {
+        var captor = ArgumentCaptor.forClass(Query.class);
+        verify(repositoryService, org.mockito.Mockito.atLeastOnce()).allMatches(captor.capture());
+        var queries = captor.getAllValues();
+        return (NamedQuery<CommandLogEntry>) queries.get(queries.size() - 1);
+    }
+
+    private static Provider<RepositoryService> providerOf(final RepositoryService service) {
+        return () -> service;
+    }
+
+    private static final class Repository
+            extends CommandLogEntryRepositoryAbstract<CommandLogEntry> {
+        private Repository() { super(CommandLogEntry.class); }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java
new file mode 100644
index 00000000000..15b288f842e
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java
@@ -0,0 +1,128 @@
+/*
+ *  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.replay;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CommandManagerEligibilityTest {
+
+    @Test
+    void generalManagerCollectionsOmitResultlessSafeEntryWithoutMutation() {
+        var fixture = fixture();
+        when(fixture.repository().findForegroundSinceTimestampAndCanBeExported(fixture.baseline()))
+                .thenReturn(fixture.entries());
+        when(fixture.repository().findForegroundSinceTimestampAndHasBeenExported(fixture.baseline()))
+                .thenReturn(fixture.entries());
+        when(fixture.repository().findSinceAndWithReplayOkOrExcluded(fixture.baseline()))
+                .thenReturn(fixture.entries());
+
+        var exportManager = new CommandExportManager(fixture.baseline(), fixture.context());
+        var replayManager = new CommandReplayManager(fixture.baseline(), fixture.context());
+
+        assertThat(exportManager.getNotYetExported()).extracting(ReplayableCommand::interactionId)
+                .containsExactly(fixture.resultBearing().getInteractionId());
+        assertThat(exportManager.getExported()).extracting(ReplayableCommand::interactionId)
+                .containsExactly(fixture.resultBearing().getInteractionId());
+        assertThat(replayManager.getSucceededOrExcluded()).extracting(ReplayableCommand::interactionId)
+                .containsExactly(fixture.resultBearing().getInteractionId());
+        verifyUnchanged(fixture.resultless());
+        verifyUnchanged(fixture.resultBearing());
+    }
+
+    @Test
+    void pendingOrFailedRetainsEveryRepositoryResult() {
+        var fixture = fixture();
+        when(fixture.repository().findForegroundSinceTimestampAndWithReplayPendingOrFailed(
+                fixture.baseline())).thenReturn(fixture.entries());
+
+        var commands = new CommandReplayManager(fixture.baseline(), fixture.context())
+                .getPendingOrFailed();
+
+        assertThat(commands).extracting(ReplayableCommand::interactionId)
+                .containsExactly(
+                        fixture.resultless().getInteractionId(),
+                        fixture.resultBearing().getInteractionId());
+        verifyUnchanged(fixture.resultless());
+        verifyUnchanged(fixture.resultBearing());
+    }
+
+    private static void verifyUnchanged(final CommandLogEntry entry) {
+        verify(entry, never()).setReplayState(any());
+        verify(entry, never()).setResult(any());
+    }
+
+    private static Fixture fixture() {
+        var baseline = Timestamp.from(Instant.parse("2026-08-05T10:00:00Z"));
+        var resultless = entry(null);
+        var resultBearing = entry(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1"));
+        var repository = mock(CommandLogEntryRepository.class);
+        var featureRepository = mock(ApplicationFeatureRepository.class);
+        var feature = mock(ApplicationFeature.class);
+        when(featureRepository.findFeature(any())).thenReturn(feature);
+        when(feature.getActionSemantics()).thenReturn(Optional.of(SemanticsOf.SAFE));
+        var context = new ReplayContext(
+                null, null, null, repository, null, null,
+                new ResultRemappingService(List.of()), null, featureRepository);
+        return new Fixture(
+                baseline, context, repository, resultless, resultBearing,
+                List.of(resultless, resultBearing));
+    }
+
+    private static CommandLogEntry entry(final Bookmark result) {
+        var dto = new CommandDto();
+        dto.setMember(new ActionDto());
+        var entry = mock(CommandLogEntry.class);
+        when(entry.getInteractionId()).thenReturn(UUID.randomUUID());
+        when(entry.getCommandDto()).thenReturn(dto);
+        when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+        when(entry.getResult()).thenReturn(result);
+        return entry;
+    }
+
+    private record Fixture(
+            Timestamp baseline,
+            ReplayContext context,
+            CommandLogEntryRepository repository,
+            CommandLogEntry resultless,
+            CommandLogEntry resultBearing,
+            List<CommandLogEntry> entries) {
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java
new file mode 100644
index 00000000000..3a0e94605ff
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java
@@ -0,0 +1,88 @@
+/*
+ *  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.replay;
+
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandEligibilityTest {
+
+    private final ApplicationFeatureRepository featureRepository = mock(ApplicationFeatureRepository.class);
+    private final ApplicationFeature feature = mock(ApplicationFeature.class);
+
+    @Test
+    void stateChangingActionIsEligibleWithoutResult() {
+        var entry = actionEntry(null);
+        actionSemantics(SemanticsOf.NON_IDEMPOTENT);
+
+        assertThat(ReplayableCommandEligibility.isEligible(entry, featureRepository)).isTrue();
+    }
+
+    @Test
+    void safeActionRequiresRecordedResult() {
+        actionSemantics(SemanticsOf.SAFE);
+
+        assertThat(ReplayableCommandEligibility.isEligible(actionEntry(null), featureRepository)).isFalse();
+        assertThat(ReplayableCommandEligibility.isEligible(
+                actionEntry(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1")),
+                featureRepository)).isTrue();
+    }
+
+    @Test
+    void propertyAndUnclassifiableEntriesAreRetainedConservatively() {
+        var propertyEntry = mock(CommandLogEntry.class);
+        when(propertyEntry.getCommandDto()).thenReturn(new CommandDto());
+        var unknownAction = actionEntry(null);
+        when(featureRepository.findFeature(any())).thenThrow(new IllegalArgumentException("unknown"));
+
+        assertThat(ReplayableCommandEligibility.isEligible(propertyEntry, featureRepository)).isTrue();
+        assertThat(ReplayableCommandEligibility.isEligible(unknownAction, featureRepository)).isTrue();
+        assertThat(ReplayableCommandEligibility.isEligible(unknownAction, null)).isTrue();
+        assertThat(ReplayableCommandEligibility.isEligible(null, featureRepository)).isFalse();
+    }
+
+    private void actionSemantics(final SemanticsOf semantics) {
+        when(featureRepository.findFeature(any())).thenReturn(feature);
+        when(feature.getActionSemantics()).thenReturn(Optional.of(semantics));
+    }
+
+    private static CommandLogEntry actionEntry(final Bookmark result) {
+        var dto = new CommandDto();
+        dto.setMember(new ActionDto());
+        var entry = mock(CommandLogEntry.class);
+        when(entry.getCommandDto()).thenReturn(dto);
+        when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+        when(entry.getResult()).thenReturn(result);
+        return entry;
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java
new file mode 100644
index 00000000000..d0a91858274
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java
@@ -0,0 +1,145 @@
+/*
+ *  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.replay;
+
+import java.sql.Timestamp;
+import java.time.ZonedDateTime;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import javax.xml.datatype.DatatypeFactory;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeature;
+import org.apache.causeway.applib.services.appfeat.ApplicationFeatureRepository;
+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.ReplayState;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandNavigationTest {
+
+    @Test
+    void nextAndPreviousSkipIneligibleEntriesAndPreserveContext() throws Exception {
+        var timestamp = ZonedDateTime.parse("2026-08-05T10:15:30Z");
+        var current = entry(timestamp, false);
+        var ineligible = entry(timestamp.plusSeconds(1), true);
+        var adjacent = entry(timestamp.plusSeconds(2), false);
+        var fixture = fixture(current);
+        when(fixture.repository().findForegroundSinceTimestamp(Timestamp.from(timestamp.toInstant())))
+                .thenReturn(List.of(current, ineligible, adjacent));
+        when(fixture.repository().findForegroundBeforeTimestamp(
+                Timestamp.from(timestamp.toInstant()), null))
+                .thenReturn(List.of(ineligible, adjacent));
+
+        var next = fixture.command().next();
+        var previous = fixture.command().previous();
+
+        assertThat(next.interactionId()).isEqualTo(adjacent.getInteractionId());
+        assertThat(previous.interactionId()).isEqualTo(adjacent.getInteractionId());
+        assertThat(next.replayContext()).isSameAs(fixture.context());
+        assertThat(previous.replayContext()).isSameAs(fixture.context());
+        assertThat(fixture.command().disableNext()).isNull();
+        assertThat(fixture.command().disablePrevious()).isNull();
+        verify(current, never()).setReplayState(any());
+        verify(ineligible, never()).setReplayState(any());
+        verify(adjacent, never()).setReplayState(any());
+    }
+
+    @Test
+    void boundariesDisableSafelyAndReturnCurrentCommand() throws Exception {
+        var timestamp = ZonedDateTime.parse("2026-08-05T10:15:30Z");
+        var current = entry(timestamp, false);
+        var fixture = fixture(current);
+        when(fixture.repository().findForegroundSinceTimestamp(any())).thenReturn(List.of(current));
+        when(fixture.repository().findForegroundBeforeTimestamp(any(), isNull())).thenReturn(List.of());
+
+        assertThat(fixture.command().disablePrevious()).isNotNull();
+        assertThat(fixture.command().disableNext()).isNotNull();
+        assertThat(fixture.command().previous()).isSameAs(fixture.command());
+        assertThat(fixture.command().next()).isSameAs(fixture.command());
+    }
+
+    @Test
+    void navigationMixinsAreSafeAndSuppressPublishing() {
+        assertSafeSuppressed(ReplayableCommand_previous.class);
+        assertSafeSuppressed(ReplayableCommand_next.class);
+    }
+
+    private static void assertSafeSuppressed(final Class<?> mixinType) {
+        var action = mixinType.getAnnotation(Action.class);
+        assertThat(action.semantics()).isEqualTo(SemanticsOf.SAFE);
+        assertThat(action.commandPublishing()).isEqualTo(Publishing.DISABLED);
+        assertThat(action.executionPublishing()).isEqualTo(Publishing.DISABLED);
+    }
+
+    private static Fixture fixture(final CommandLogEntry current) {
+        var repository = mock(CommandLogEntryRepository.class);
+        when(repository.findByInteractionId(current.getInteractionId())).thenReturn(Optional.of(current));
+        var featureRepository = mock(ApplicationFeatureRepository.class);
+        var feature = mock(ApplicationFeature.class);
+        when(featureRepository.findFeature(any())).thenReturn(feature);
+        when(feature.getActionSemantics()).thenReturn(Optional.of(SemanticsOf.SAFE));
+        var context = new ReplayContext(
+                null, null, null, repository, null, null,
+                new ResultRemappingService(List.of()), null, featureRepository);
+        return new Fixture(
+                new ReplayableCommand(current.getInteractionId(), context),
+                context,
+                repository);
+    }
+
+    private static CommandLogEntry entry(
+            final ZonedDateTime timestamp,
+            final boolean safeActionWithoutResult) throws Exception {
+        var dto = new CommandDto();
+        dto.setTimestamp(DatatypeFactory.newInstance()
+                .newXMLGregorianCalendar(GregorianCalendar.from(timestamp)));
+        if (safeActionWithoutResult) {
+            dto.setMember(new ActionDto());
+        }
+        var entry = mock(CommandLogEntry.class);
+        when(entry.getInteractionId()).thenReturn(UUID.randomUUID());
+        when(entry.getCommandDto()).thenReturn(dto);
+        when(entry.getReplayState()).thenReturn(ReplayState.PENDING);
+        when(entry.getLogicalMemberIdentifier()).thenReturn("demo.Customer#find");
+        return entry;
+    }
+
+    private record Fixture(
+            ReplayableCommand command,
+            ReplayContext context,
+            CommandLogEntryRepository repository) {
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java
new file mode 100644
index 00000000000..0179885e195
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java
@@ -0,0 +1,247 @@
+/*
+ *  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.replay;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.bookmark.BookmarkService;
+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.ReplayState;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayableCommandParticipant.Role;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListener;
+import org.apache.causeway.schema.cmd.v2.ActionDto;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+import org.apache.causeway.schema.cmd.v2.ParamDto;
+import org.apache.causeway.schema.cmd.v2.ParamsDto;
+import org.apache.causeway.schema.common.v2.OidDto;
+import org.apache.causeway.schema.common.v2.OidsDto;
+import org.apache.causeway.schema.common.v2.ValueType;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandParticipantTest {
+
+    @Test
+    void derivesTargetReferenceParameterAndResultButNotScalarParameter() {
+        var fixture = fixture(ReplayState.PENDING, null);
+
+        var participants = fixture.command().getParticipants();
+
+        assertThat(fixture.command().getHasResult()).isTrue();
+        assertThat(participants).extracting(ReplayableCommandParticipant::getRole)
+                .containsExactly(Role.TARGET, Role.PARAMETER, Role.RESULT);
+        assertThat(participants.get(1).getParameterName()).isEqualTo("customer");
+        assertThat(participants).extracting(ReplayableCommandParticipant::getRecordedBookmark)
+                .containsExactly(fixture.target(), fixture.parameter(), fixture.result());
+        verify(fixture.bookmarkService(), never()).lookup(fixture.result());
+    }
+
+    @Test
+    void mappedTargetAndParameterAppearWhilePendingButUnmappedParticipantsWaitForSuccess() {
+        var mappedTarget = bookmark("Customer", "2");
+        var fixture = fixture(ReplayState.PENDING, mapping(recorded ->
+                recorded.equals(bookmark("Order", "1")) ? Optional.of(mappedTarget) : Optional.empty()));
+
+        var participants = fixture.command().getParticipants();
+
+        assertThat(participant(participants, Role.TARGET).getActualBookmark()).isEqualTo(mappedTarget);
+        assertThat(participant(participants, Role.PARAMETER).getActualBookmark()).isNull();
+        assertThat(participant(participants, Role.RESULT).getActualBookmark()).isNull();
+    }
+
+    @Test
+    void successfulReplayUsesMappingAndRecordedBookmarkFallback() {
+        var mappedResult = bookmark("Invoice", "2");
+        var fixture = fixture(ReplayState.OK, mapping(recorded ->
+                recorded.equals(bookmark("Invoice", "1")) ? Optional.of(mappedResult) : Optional.empty()));
+
+        var participants = fixture.command().getParticipants();
+
+        assertThat(participant(participants, Role.TARGET).getActualBookmark()).isEqualTo(fixture.target());
+        assertThat(participant(participants, Role.PARAMETER).getActualBookmark()).isEqualTo(fixture.parameter());
+        assertThat(participant(participants, Role.RESULT).getActualBookmark()).isEqualTo(mappedResult);
+    }
+
+    @Test
+    void roleSpecificObjectLinksResolveActualBookmarkBestEffort() {
+        var mappedTarget = bookmark("Order", "2");
+        var targetObject = new Object();
+        var fixture = fixture(ReplayState.PENDING, mapping(recorded ->
+                recorded.equals(bookmark("Order", "1")) ? Optional.of(mappedTarget) : Optional.empty()));
+        when(fixture.bookmarkService().lookup(mappedTarget)).thenReturn(Optional.of(targetObject));
+
+        var target = participant(fixture.command().getParticipants(), Role.TARGET);
+        var parameter = participant(fixture.command().getParticipants(), Role.PARAMETER);
+
+        assertThat(target.getTarget()).isSameAs(targetObject);
+        assertThat(target.getArgument()).isNull();
+        assertThat(target.getResult()).isNull();
+        assertThat(target.hideTarget()).isFalse();
+        assertThat(target.hideArgument()).isTrue();
+        assertThat(parameter.getArgument()).isNull();
+        assertThat(parameter.getRecordedBookmark()).isEqualTo(fixture.parameter());
+    }
+
+    @Test
+    void readableMementosRehydrateFromCurrentCommandAndMappingState() {
+        var actualParameter = bookmark("Customer", "2");
+        var mapping = new MutableMapping();
+        var fixture = fixture(ReplayState.OK, mapping);
+        var parameter = participant(fixture.command().getParticipants(), Role.PARAMETER);
+        var expectedMemento = fixture.interactionId() + "--parameter--customer";
+
+        assertThat(parameter.viewModelMemento()).isEqualTo(expectedMemento);
+        assertThat(parameter.viewModelMemento()).doesNotContain(fixture.parameter().stringify());
+
+        mapping.actual = actualParameter;
+        var rehydrated = new ReplayableCommandParticipant(expectedMemento, fixture.context());
+
+        assertThat(rehydrated.getReplayableCommand().interactionId()).isEqualTo(fixture.interactionId());
+        assertThat(rehydrated.getRecordedBookmark()).isEqualTo(fixture.parameter());
+        assertThat(rehydrated.getActualBookmark()).isEqualTo(actualParameter);
+        assertThat(rehydrated.title()).contains("PARAMETER", "customer", "→");
+    }
+
+    @Test
+    void reportsNoResultWhenEntryHasNoRecordedResult() {
+        var fixture = fixture(ReplayState.PENDING, null);
+        when(fixture.entry().getResult()).thenReturn(null);
+
+        assertThat(fixture.command().getHasResult()).isFalse();
+        assertThat(fixture.command().getParticipants())
+                .extracting(ReplayableCommandParticipant::getRole)
+                .doesNotContain(Role.RESULT);
+    }
+
+    private static ReplayableCommandParticipant participant(
+            final List<ReplayableCommandParticipant> participants,
+            final Role role) {
+        return participants.stream()
+                .filter(candidate -> candidate.getRole() == role)
+                .findFirst()
+                .orElseThrow();
+    }
+
+    private static Fixture fixture(
+            final ReplayState replayState,
+            final CommandReplayMappingListener listener) {
+        var interactionId = UUID.randomUUID();
+        var target = bookmark("Order", "1");
+        var parameter = bookmark("Customer", "1");
+        var result = bookmark("Invoice", "1");
+        var dto = commandDto(target, parameter);
+        var entry = mock(CommandLogEntry.class);
+        when(entry.getInteractionId()).thenReturn(interactionId);
+        when(entry.getCommandDto()).thenReturn(dto);
+        when(entry.getReplayState()).thenReturn(replayState);
+        when(entry.getResult()).thenReturn(result);
+        var repository = mock(CommandLogEntryRepository.class);
+        when(repository.findByInteractionId(interactionId)).thenReturn(Optional.of(entry));
+        var bookmarkService = mock(BookmarkService.class);
+        var remappingService = new ResultRemappingService(
+                listener != null ? List.of(listener) : List.of());
+        var context = new ReplayContext(
+                null, null, null, repository, null, null, remappingService, bookmarkService, null);
+        return new Fixture(
+                interactionId,
+                new ReplayableCommand(interactionId, context),
+                context,
+                entry,
+                bookmarkService,
+                target,
+                parameter,
+                result);
+    }
+
+    private static CommandDto commandDto(final Bookmark target, final Bookmark reference) {
+        var dto = new CommandDto();
+        var targets = new OidsDto();
+        targets.getOid().add(oid(target));
+        dto.setTargets(targets);
+        var action = new ActionDto();
+        action.setLogicalMemberIdentifier("demo.Order#place");
+        var params = new ParamsDto();
+        var referenceParam = new ParamDto();
+        referenceParam.setName("customer");
+        referenceParam.setType(ValueType.REFERENCE);
+        referenceParam.setReference(oid(reference));
+        params.getParameter().add(referenceParam);
+        var scalarParam = new ParamDto();
+        scalarParam.setName("quantity");
+        scalarParam.setType(ValueType.INT);
+        params.getParameter().add(scalarParam);
+        action.setParameters(params);
+        dto.setMember(action);
+        return dto;
+    }
+
+    private static OidDto oid(final Bookmark bookmark) {
+        var oid = new OidDto();
+        oid.setType(bookmark.logicalTypeName());
+        oid.setId(bookmark.identifier());
+        return oid;
+    }
+
+    private static Bookmark bookmark(final String type, final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo." + type, id);
+    }
+
+    private static CommandReplayMappingListener mapping(
+            final Function<Bookmark, Optional<Bookmark>> lookup) {
+        return new CommandReplayMappingListener() {
+            @Override
+            public Optional<Bookmark> lookup(final Bookmark recordedBookmark) {
+                return lookup.apply(recordedBookmark);
+            }
+        };
+    }
+
+    private record Fixture(
+            UUID interactionId,
+            ReplayableCommand command,
+            ReplayContext context,
+            CommandLogEntry entry,
+            BookmarkService bookmarkService,
+            Bookmark target,
+            Bookmark parameter,
+            Bookmark result) {
+    }
+
+    private static final class MutableMapping implements CommandReplayMappingListener {
+        private Bookmark actual;
+
+        @Override
+        public Optional<Bookmark> lookup(final Bookmark recordedBookmark) {
+            return actual != null && recordedBookmark.equals(bookmark("Customer", "1"))
+                    ? Optional.of(actual)
+                    : Optional.empty();
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java
new file mode 100644
index 00000000000..8e20a79f952
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java
@@ -0,0 +1,92 @@
+/*
+ *  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.replay;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Import;
+
+import org.apache.causeway.applib.annotation.Programmatic;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.Where;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ReplayableCommandPresentationTest {
+
+    @Test
+    void replayableCommandLayoutShowsParticipantsBeforeControlsWithoutTargetSummary() throws Exception {
+        var layout = resource("ReplayableCommand.layout.fallback.xml");
+
+        assertThat(layout).contains("id=\"hasResult\"", "id=\"participants\"", "id=\"control\"");
+        assertThat(layout.indexOf("id=\"participants\"")).isLessThan(layout.indexOf("id=\"control\""));
+        assertThat(layout).doesNotContain("id=\"targetType\"", "id=\"targetId\"", "id=\"openTarget\"");
+        assertThat(ReplayableCommand.class.getMethod("getTargetType").isAnnotationPresent(Programmatic.class))
+                .isTrue();
+        assertThat(ReplayableCommand.class.getMethod("getTargetId").isAnnotationPresent(Programmatic.class))
+                .isTrue();
+    }
+
+    @Test
+    void participantLayoutAndColumnOrderFollowRecordedActualContract() throws Exception {
+        var layout = resource("ReplayableCommandParticipant.layout.fallback.xml");
+        var columns = resource("ReplayableCommandParticipant.columnOrder.fallback.txt")
+                .lines().toList();
+
+        assertThat(layout).containsSubsequence(
+                "span=\"4\"", "span=\"4\"", "span=\"4\"");
+        assertThat(layout).contains(
+                "id=\"replayableCommand\"", "id=\"role\"", "id=\"parameterName\"",
+                "id=\"logicalTypeName\"", "id=\"recordedBookmark\"", "id=\"target\"",
+                "id=\"argument\"", "id=\"actualBookmark\"", "id=\"result\"");
+        assertThat(layout).doesNotContain("id=\"owningInteractionId\"");
+        assertThat(columns).containsExactly(
+                "#replayableCommand", "role", "parameterName", "#recordedBookmark",
+                "target", "argument", "result", "#actualBookmark");
+        var owningIdLayout = ReplayableCommandParticipant.class
+                .getMethod("getOwningInteractionId")
+                .getAnnotation(PropertyLayout.class);
+        assertThat(owningIdLayout.hidden()).isEqualTo(Where.OBJECT_FORMS);
+    }
+
+    @Test
+    void moduleRegistersParticipantAndNavigationButNotLegacyTargetMixins() {
+        var imports = List.of(CausewayModuleExtCommandLogApplib.class
+                .getAnnotation(Import.class).value());
+
+        assertThat(imports).contains(
+                ReplayableCommandParticipant.class,
+                ReplayableCommand_previous.class,
+                ReplayableCommand_next.class);
+        assertThat(imports).doesNotContain(
+                ReplayableCommand_openTarget.class,
+                ReplayableCommand_openTargetTR.class);
+    }
+
+    private static String resource(final String name) throws IOException {
+        try (var stream = ReplayableCommand.class.getResourceAsStream(name)) {
+            assertThat(stream).as(name).isNotNull();
+            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+    }
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
index 65c156e70e0..8ef25d85203 100644
--- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandLogEntry.java
@@ -208,6 +208,20 @@
                   + " WHERE cl.startedAt   is not null "
                   + "   AND cl.completedAt is not null "
                   + " ORDER BY cl.timestamp DESC"), // programmatic LIMIT 1
+    @NamedQuery(
+            name  = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER,
+            query = "SELECT cl "
+                  + " FROM CommandLogEntry cl "
+                  + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND "
+                  + "   AND cl.timestamp >= :from "
+                  + " ORDER BY cl.timestamp ASC"),
+    @NamedQuery(
+            name  = Nq.FIND_FOREGROUND_BY_TIMESTAMP_BEFORE,
+            query = "SELECT cl "
+                  + " FROM CommandLogEntry cl "
+                  + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND "
+                  + "   AND cl.timestamp < :to "
+                  + " ORDER BY cl.timestamp DESC"),
     @NamedQuery(
             name  = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE,
             query = "SELECT cl "
diff --git a/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java
new file mode 100644
index 00000000000..8fbc9c758ee
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java
@@ -0,0 +1,89 @@
+/*
+ *  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.integtests;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.iactn.InteractionService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn;
+import org.apache.causeway.extensions.commandlog.applib.dom.ReplayState;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntry;
+import org.apache.causeway.testing.integtestsupport.applib.CausewayIntegrationTestAbstract;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(classes = AppManifest.class)
+@ActiveProfiles("test")
+class CommandLogEntryAdjacentQuery_IntegTest extends CausewayIntegrationTestAbstract {
+
+    @Inject CommandLogEntryRepository repository;
+    @Inject InteractionService interactionService;
+    @Inject RepositoryService repositoryService;
+
+    @BeforeEach
+    void setUp() {
+        interactionService.nextInteraction();
+        repositoryService.removeAll(CommandLogEntry.class);
+    }
+
+    @Test
+    void foregroundQueriesUseExpectedDirectionBoundaryAndLimit() {
+        var first = persist("2026-08-05T10:00:00Z", ExecuteIn.FOREGROUND);
+        persist("2026-08-05T10:30:00Z", ExecuteIn.BACKGROUND);
+        var second = persist("2026-08-05T11:00:00Z", ExecuteIn.FOREGROUND);
+        var third = persist("2026-08-05T12:00:00Z", ExecuteIn.FOREGROUND);
+
+        assertThat(repository.findForegroundSinceTimestamp(timestamp("2026-08-05T10:30:00Z")))
+                .containsExactly(second, third);
+        assertThat(repository.findForegroundSinceTimestamp(timestamp("2026-08-05T10:00:00Z"), 1))
+                .containsExactly(first);
+        assertThat(repository.findForegroundBeforeTimestamp(timestamp("2026-08-05T12:00:00Z"), null))
+                .containsExactly(second, first);
+    }
+
+    private CommandLogEntry persist(final String instant, final ExecuteIn executeIn) {
+        var entry = new CommandLogEntry();
+        entry.setInteractionId(UUID.randomUUID());
+        entry.setUsername("tester");
+        entry.setTimestamp(timestamp(instant));
+        entry.setTarget(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1"));
+        entry.setExecuteIn(executeIn);
+        entry.setLogicalMemberIdentifier("demo.Customer#update");
+        entry.setReplayState(ReplayState.UNDEFINED);
+        repository.persist(entry);
+        return entry;
+    }
+
+    private static Timestamp timestamp(final String instant) {
+        return Timestamp.from(Instant.parse(instant));
+    }
+}
diff --git a/openspec/changes/reconcile-replayable-command-projection/tasks.md b/openspec/changes/reconcile-replayable-command-projection/tasks.md
index 65ae697ab24..fcabc82f448 100644
--- a/openspec/changes/reconcile-replayable-command-projection/tasks.md
+++ b/openspec/changes/reconcile-replayable-command-projection/tasks.md
@@ -1,35 +1,35 @@
 ## 1. Eligibility and Ordered Lookup Foundations
 
-- [ ] 1.1 Add a reusable replayable-command eligibility policy that retains state-changing and unclassifiable entries while excluding safe actions without recorded results.
-- [ ] 1.2 Extend `ReplayContext` compatibly with `BookmarkService` and `ApplicationFeatureRepository`, updating module wiring and existing fixtures without changing replay transaction behavior or depending on core metamodel internals.
-- [ ] 1.3 Add persistence-neutral foreground-before and foreground-since repository operations and reusable query behavior, with the corresponding Jakarta Persistence named queries and no schema changes.
-- [ ] 1.4 Add focused applib and JPA tests for state-changing, result-bearing safe, resultless safe, and unclassifiable eligibility plus ordered foreground repository lookup.
+- [x] 1.1 Add a reusable replayable-command eligibility policy that retains state-changing and unclassifiable entries while excluding safe actions without recorded results.
+- [x] 1.2 Extend `ReplayContext` compatibly with `BookmarkService` and `ApplicationFeatureRepository`, updating module wiring and existing fixtures without changing replay transaction behavior or depending on core metamodel internals.
+- [x] 1.3 Add persistence-neutral foreground-before and foreground-since repository operations and reusable query behavior, with the corresponding Jakarta Persistence named queries and no schema changes.
+- [x] 1.4 Add focused applib and JPA tests for state-changing, result-bearing safe, resultless safe, and unclassifiable eligibility plus ordered foreground repository lookup.
 
 ## 2. Participant Projection
 
-- [ ] 2.1 Add the documented public `ReplayableCommandParticipant` view model, roles, role-specific optional object properties, human-readable title, and identity-only memento parsing and serialization.
-- [ ] 2.2 Enrich `ReplayableCommand` with non-persisted `hasResult` and derived target, reference-parameter, and result participants while omitting scalar parameters.
-- [ ] 2.3 Resolve target and parameter actual bookmarks through `ResultRemappingService`, using explicit mappings in any state and recorded-bookmark fallback only after replay state `OK`.
-- [ ] 2.4 Gate result actual bookmarks on replay state `OK`, then use the configured mapping or recorded-bookmark fallback, without depending directly on persistent mapping storage.
-- [ ] 2.5 Rehydrate participant bookmarks and object links from the owning command and current mapping state, resolving local objects best-effort through `BookmarkService`.
-- [ ] 2.6 Add focused tests for participant derivation, mapping and replay-state combinations, object resolution, result presence, titles, readable mementos, and rehydration.
+- [x] 2.1 Add the documented public `ReplayableCommandParticipant` view model, roles, role-specific optional object properties, human-readable title, and identity-only memento parsing and serialization.
+- [x] 2.2 Enrich `ReplayableCommand` with non-persisted `hasResult` and derived target, reference-parameter, and result participants while omitting scalar parameters.
+- [x] 2.3 Resolve target and parameter actual bookmarks through `ResultRemappingService`, using explicit mappings in any state and recorded-bookmark fallback only after replay state `OK`.
+- [x] 2.4 Gate result actual bookmarks on replay state `OK`, then use the configured mapping or recorded-bookmark fallback, without depending directly on persistent mapping storage.
+- [x] 2.5 Rehydrate participant bookmarks and object links from the owning command and current mapping state, resolving local objects best-effort through `BookmarkService`.
+- [x] 2.6 Add focused tests for participant derivation, mapping and replay-state combinations, object resolution, result presence, titles, readable mementos, and rehydration.
 
 ## 3. Navigation and Presentation
 
-- [ ] 3.1 Add previous/next replayable-command behavior that scans foreground ordering, skips ineligible entries, preserves `ReplayContext`, and disables safely at boundaries.
-- [ ] 3.2 Add and register safe command-recording-suppressed previous/next mixins with focused action and non-mutation tests.
-- [ ] 3.3 Add participant fallback layout and table column order, and update replayable-command layout/columns to show `hasResult` and Participants before controls while removing redundant target presentation.
-- [ ] 3.4 Register the participant view model and new mixins in the commandlog applib module and add focused metadata/layout/order tests.
+- [x] 3.1 Add previous/next replayable-command behavior that scans foreground ordering, skips ineligible entries, preserves `ReplayContext`, and disables safely at boundaries.
+- [x] 3.2 Add and register safe command-recording-suppressed previous/next mixins with focused action and non-mutation tests.
+- [x] 3.3 Add participant fallback layout and table column order, and update replayable-command layout/columns to show `hasResult` and Participants before controls while removing redundant target presentation.
+- [x] 3.4 Register the participant view model and new mixins in the commandlog applib module and add focused metadata/layout/order tests.
 
 ## 4. Existing Manager Adapters
 
-- [ ] 4.1 Apply the shared eligibility policy to existing export-manager collections without changing their logical type, baseline, memento, export actions, or replay-state filters.
-- [ ] 4.2 Apply the shared eligibility policy to the existing completed-or-excluded replay collection while leaving pending-or-failed repository results unfiltered.
-- [ ] 4.3 Add regression tests proving resultless safe commands are omitted from general existing-manager projections, retained in pending-or-failed work, and never deleted or mutated.
+- [x] 4.1 Apply the shared eligibility policy to existing export-manager collections without changing their logical type, baseline, memento, export actions, or replay-state filters.
+- [x] 4.2 Apply the shared eligibility policy to the existing completed-or-excluded replay collection while leaving pending-or-failed repository results unfiltered.
+- [x] 4.3 Add regression tests proving resultless safe commands are omitted from general existing-manager projections, retained in pending-or-failed work, and never deleted or mutated.
 
 ## 5. Documentation and Verification
 
-- [ ] 5.1 Document result presence, participant roles, recorded/actual bookmark semantics, object availability, mementos, eligibility, adjacent navigation, and the temporary use of separate managers until P2.
-- [ ] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing replay mapping, persistent mapping, export manager, and replay manager regressions.
-- [ ] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
-- [ ] 5.4 Confirm the implementation does not introduce the P2 unified manager or R1/R2, E1/W1, B1/B2 behavior and does not restore commandlog JDO.
+- [x] 5.1 Document result presence, participant roles, recorded/actual bookmark semantics, object availability, mementos, eligibility, adjacent navigation, and the temporary use of separate managers until P2.
+- [x] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing replay mapping, persistent mapping, export manager, and replay manager regressions.
+- [x] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
+- [x] 5.4 Confirm the implementation does not introduce the P2 unified manager or R1/R2, E1/W1, B1/B2 behavior and does not restore commandlog JDO.