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:49 UTC

(causeway) branch main updated (f731283c587 -> 4d9156d2c24)

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

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


    from f731283c587 CAUSEWAY-3910: test approvals
     new 7aec813586a adds support for .codex
     new c57c9716fb7 sorts out skills for .agents and .codex (as well as .pi)
     new e3fa23a20da CAUSEWAY-3910: plan persistent replay mapping reconciliation
     new 888bb63fa9c CAUSEWAY-3910: persist command replay result mappings
     new bf4db28cd0f CAUSEWAY-3910: archive persistent replay mapping change
     new 611eda3992f CAUSEWAY-3910: update reconciliation ledger for M3
     new 4d2031ea6db CAUSEWAY-3910: plan replayable command projection reconciliation
     new 576f7b49a5f CAUSEWAY-3910: reconcile replayable command projection
     new 6dfc86f127f CAUSEWAY-3910: archive replayable command projection change
     new cdcb37a4677 CAUSEWAY-3910: update reconciliation ledger for P1
     new fd0fdc012d4 CAUSEWAY-3910: propose unified command manager reconciliation
     new 0448c8cf384 CAUSEWAY-3910: reconcile unified command manager
     new 722c1497b09 CAUSEWAY-3910: archive unified command manager reconciliation
     new 43e42014ab0 CAUSEWAY-3910: update reconciliation ledger for P2
     new 4d9156d2c24 CAUSEWAY-3910: propose command reference data reconciliation

The 15 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../skills/openspec-apply-change/SKILL.md          |   0
 .../skills/openspec-archive-change/SKILL.md        |   0
 {.pi => .agents}/skills/openspec-explore/SKILL.md  |   0
 {.pi => .agents}/skills/openspec-oneshot/SKILL.md  |   0
 {.pi => .agents}/skills/openspec-propose/SKILL.md  |   0
 .../skills/openspec-sync-specs/SKILL.md            |   0
 .../skills/openspec-update-change/SKILL.md         |   0
 .codex/skills                                      |   1 +
 .pi/prompts/opsx-apply.md                          | 149 +++++++++++-
 .pi/prompts/opsx-archive.md                        | 154 ++++++++++++-
 .pi/prompts/opsx-explore.md                        | 170 +++++++++++++-
 .pi/prompts/opsx-propose.md                        | 103 ++++++++-
 .pi/skills                                         |   1 +
 .../commandlog/applib/app/CommandLogMenu.adoc      |   5 +-
 .../applib/dom/replay/CommandManager.adoc          |  36 +++
 .../config/pages/sections/causeway.extensions.adoc |   3 +-
 .../core/config/CausewayConfiguration.java         |   3 +-
 .../adoc/modules/commandlog/pages/about.adoc       |  56 ++++-
 .../applib/CausewayModuleExtCommandLogApplib.java  |  37 ++-
 .../commandlog/applib/app/CommandLogMenu.java      | 153 +++++++++++++
 .../commandlog/applib/dom/CommandLogEntry.java     |   3 +
 .../applib/dom/CommandLogEntryRepository.java      |  12 +
 .../dom/CommandLogEntryRepositoryAbstract.java     |  49 ++++
 .../applib/dom/CommandReplayResultMapping.java     | 178 +++++++++++++++
 .../CommandReplayResultMapping.layout.fallback.xml |  67 ++----
 .../CommandReplayResultMappingRepository.java}     |  42 ++--
 ...mmandReplayResultMappingRepositoryAbstract.java | 103 +++++++++
 .../commandlog/applib/dom/ReplayState.java         |  11 +
 .../mixins/CommandReplayResultMapping_delete.java  |  44 ++--
 .../applib/dom/replay/CommandExportManager.java    |  18 ++
 ...er#commandsInSequence.columnOrder.fallback.txt} |   5 +-
 ...mmandManager#excluded.columnOrder.fallback.txt} |   5 +-
 ...nager#pendingOrFailed.columnOrder.fallback.txt} |   5 +-
 ...er#recordedOrReplayed.columnOrder.fallback.txt} |   5 +-
 .../applib/dom/replay/CommandManager.java          | 184 +++++++++++++++
 .../dom/replay/CommandManager.layout.fallback.xml  |  35 +++
 .../applib/dom/replay/CommandReplayManager.java    |  16 ++
 .../commandlog/applib/dom/replay/HasBaseline.java  |  14 +-
 .../dom/replay/HasBaseline_changeBaseline.java     |  38 ++--
 .../applib/dom/replay/HasBaseline_nextHour.java    |  36 ++-
 .../dom/replay/HasBaseline_previousHour.java       |  36 ++-
 .../commandlog/applib/dom/replay/HasLimit.java     |  18 +-
 .../applib/dom/replay/HasLimit_changeLimit.java    |  40 ++--
 .../applib/dom/replay/ReplayContext.java           |  18 +-
 .../ReplayableCommand.columnOrder.fallback.txt     |   5 +-
 .../applib/dom/replay/ReplayableCommand.java       | 197 ++++++++++++----
 .../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  |  37 ++-
 .../dom/replay/ReplayableCommand_previous.java     |  37 ++-
 .../replay/ReplayableCommand_replayOrRetry.java    |   6 +-
 ...=> CommandReplayMappingListenerPersistent.java} |  64 +++---
 .../commandlog/applib/app/CommandLogMenuTest.java  | 200 +++++++++++++++++
 ...CommandLogEntryRepositoryAdjacentQueryTest.java | 107 +++++++++
 ...dReplayResultMappingRepositoryAbstractTest.java | 147 ++++++++++++
 .../applib/dom/CommandReplayResultMappingTest.java |  72 ++++++
 .../CommandReplayResultMapping_deleteTest.java     |  73 ++++++
 .../dom/replay/CommandManagerCollectionsTest.java  | 146 ++++++++++++
 .../replay/CommandManagerCompatibilityTest.java    | 102 +++++++++
 .../dom/replay/CommandManagerEligibilityTest.java  | 138 ++++++++++++
 .../applib/dom/replay/CommandManagerStateTest.java |  93 ++++++++
 .../replay/CommandRecordingSuppressionTest.java    |   1 +
 .../replay/ReplayableCommandEligibilityTest.java   |  88 ++++++++
 .../replay/ReplayableCommandNavigationTest.java    | 145 ++++++++++++
 .../replay/ReplayableCommandParticipantTest.java   | 247 ++++++++++++++++++++
 .../replay/ReplayableCommandPresentationTest.java  |  92 ++++++++
 .../replay/ReplayableCommandReplayStateTest.java   |  72 ++++++
 ...CommandReplayMappingListenerPersistentTest.java | 191 ++++++++++++++++
 .../CausewayModuleExtCommandLogPersistenceJpa.java |  12 +-
 .../commandlog/jpa/dom/CommandLogEntry.java        |  22 ++
 .../jpa/dom/CommandReplayResultMapping.java        | 117 ++++++++++
 ...a => CommandReplayResultMappingRepository.java} |  21 +-
 .../CommandLogEntryAdjacentQuery_IntegTest.java    | 117 ++++++++++
 .../CommandReplayResultMapping_IntegTest.java      | 117 ++++++++++
 .../.openspec.yaml                                 |   2 +-
 .../design.md                                      |  89 ++++++++
 .../proposal.md                                    |  32 +++
 .../persistent-command-replay-mapping/spec.md      | 187 +++++++++++++++
 .../tasks.md                                       |  34 +++
 .../.openspec.yaml                                 |   2 +-
 .../design.md                                      |  87 +++++++
 .../proposal.md                                    |  29 +++
 .../specs/replayable-command-projection/spec.md    | 189 ++++++++++++++++
 .../tasks.md                                       |  35 +++
 .../.openspec.yaml                                 |   2 +-
 .../design.md                                      |  94 ++++++++
 .../proposal.md                                    |  31 +++
 .../specs/replayable-command-projection/spec.md    |  63 ++++++
 .../specs/unified-command-manager/spec.md          | 114 ++++++++++
 .../tasks.md                                       |  35 +++
 .../.openspec.yaml                                 |   2 +-
 .../reconcile-command-reference-data/design.md     |  70 ++++++
 .../reconcile-command-reference-data/proposal.md   |  29 +++
 .../specs/command-export-refdata-marker/spec.md    |  53 +++++
 .../spec.md                                        |  48 ++++
 .../reconcile-command-reference-data/tasks.md      |  27 +++
 .../reconciliation/maintenance-branch/ledger.md    |  16 +-
 .../persistent-command-replay-mapping/spec.md      | 193 ++++++++++++++++
 .../specs/replayable-command-projection/spec.md    | 217 ++++++++++++++++++
 openspec/specs/unified-command-manager/spec.md     | 120 ++++++++++
 103 files changed, 6336 insertions(+), 409 deletions(-)
 rename {.pi => .agents}/skills/openspec-apply-change/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-archive-change/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-explore/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-oneshot/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-propose/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-sync-specs/SKILL.md (100%)
 rename {.pi => .agents}/skills/openspec-update-change/SKILL.md (100%)
 create mode 120000 .codex/skills
 create mode 120000 .pi/skills
 create mode 100644 antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/dom/replay/CommandManager.adoc
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java
 copy regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/JpaInventoryJaxbVm.layout.xml => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.fallback.xml (52%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/{spi/CommandReplayMappingListener.java => dom/CommandReplayResultMappingRepository.java} (54%)
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java
 copy incubator/extensions/core/commandreplay/primary/src/main/java/org/apache/causeway/extensions/commandreplay/primary/mixins/CommandLogEntry_retry.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_delete.java (59%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/{CommandExportManager#exported.columnOrder.fallback.txt => CommandManager#commandsInSequence.columnOrder.fallback.txt} (50%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/{CommandExportManager#exported.columnOrder.fallback.txt => CommandManager#excluded.columnOrder.fallback.txt} (50%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/{CommandExportManager#exported.columnOrder.fallback.txt => CommandManager#pendingOrFailed.columnOrder.fallback.txt} (50%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/{CommandExportManager#exported.columnOrder.fallback.txt => CommandManager#recordedOrReplayed.columnOrder.fallback.txt} (50%)
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.java
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.layout.fallback.xml
 copy core/metamodel/src/main/java/org/apache/causeway/core/metamodel/services/objectlifecycle/HasEnlistedEntityPropertyChanges.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline.java (67%)
 copy api/applib/src/main/java/org/apache/causeway/applib/services/bookmark/BookmarkHolder_lookup.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_changeBaseline.java (60%)
 copy api/applib/src/main/java/org/apache/causeway/applib/services/bookmark/BookmarkHolder_lookup.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_nextHour.java (62%)
 copy api/applib/src/main/java/org/apache/causeway/applib/services/bookmark/BookmarkHolder_lookup.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_previousHour.java (62%)
 copy api/applib/src/main/java/org/apache/causeway/applib/clock/VirtualClock_system.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit.java (69%)
 copy api/applib/src/main/java/org/apache/causeway/applib/services/bookmark/BookmarkHolder_lookup.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit_changeLimit.java (58%)
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibility.java
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.columnOrder.fallback.txt
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.java
 create mode 100644 extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipant.layout.fallback.xml
 copy regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/entities/JpaBook_delete.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_next.java (63%)
 copy regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/entities/JpaBook_delete.java => extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_previous.java (63%)
 copy extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/{CommandReplayMappingListenerInMemory.java => CommandReplayMappingListenerPersistent.java} (59%)
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandLogEntryRepositoryAdjacentQueryTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstractTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_deleteTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCollectionsTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCompatibilityTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerEligibilityTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerStateTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandEligibilityTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandNavigationTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandParticipantTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandPresentationTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandReplayStateTest.java
 create mode 100644 extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
 create mode 100644 extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java
 copy extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/{CommandLogEntryRepository.java => CommandReplayResultMappingRepository.java} (70%)
 create mode 100644 extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandLogEntryAdjacentQuery_IntegTest.java
 create mode 100644 extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandReplayResultMapping_IntegTest.java
 copy openspec/changes/archive/{2026-08-04-reconcile-command-recording-core-policy => 2026-08-05-reconcile-persistent-replay-mapping}/.openspec.yaml (50%)
 create mode 100644 openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/design.md
 create mode 100644 openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/proposal.md
 create mode 100644 openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
 create mode 100644 openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/tasks.md
 copy openspec/changes/archive/{2026-08-04-reconcile-command-recording-core-policy => 2026-08-06-reconcile-replayable-command-projection}/.openspec.yaml (50%)
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/design.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/proposal.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/tasks.md
 copy openspec/changes/archive/{2026-08-04-reconcile-command-recording-core-policy => 2026-08-06-reconcile-unified-command-manager}/.openspec.yaml (50%)
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/design.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/proposal.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/unified-command-manager/spec.md
 create mode 100644 openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/tasks.md
 copy openspec/changes/{archive/2026-08-04-reconcile-command-recording-core-policy => reconcile-command-reference-data}/.openspec.yaml (50%)
 create mode 100644 openspec/changes/reconcile-command-reference-data/design.md
 create mode 100644 openspec/changes/reconcile-command-reference-data/proposal.md
 create mode 100644 openspec/changes/reconcile-command-reference-data/specs/command-export-refdata-marker/spec.md
 create mode 100644 openspec/changes/reconcile-command-reference-data/specs/command-export-reference-data-participants/spec.md
 create mode 100644 openspec/changes/reconcile-command-reference-data/tasks.md
 create mode 100644 openspec/specs/persistent-command-replay-mapping/spec.md
 create mode 100644 openspec/specs/replayable-command-projection/spec.md
 create mode 100644 openspec/specs/unified-command-manager/spec.md


(causeway) 01/15: adds support for .codex

Posted by da...@apache.org.
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 7aec813586a4fa07d35b2f9dfe0011fb1db10db1
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 21:16:36 2026 +0100

    adds support for .codex
---
 .agents/skills                                     |   1 +
 .../skills/openspec-apply-change/SKILL.md          |   0
 .../skills/openspec-archive-change/SKILL.md        |   0
 {.pi => .codex}/skills/openspec-explore/SKILL.md   |   0
 {.pi => .codex}/skills/openspec-oneshot/SKILL.md   |   0
 {.pi => .codex}/skills/openspec-propose/SKILL.md   |   0
 .../skills/openspec-sync-specs/SKILL.md            |   0
 .../skills/openspec-update-change/SKILL.md         |   0
 .pi/prompts/opsx-apply.md                          | 149 +++++++++++++++++-
 .pi/prompts/opsx-archive.md                        | 154 ++++++++++++++++++-
 .pi/prompts/opsx-explore.md                        | 170 ++++++++++++++++++++-
 .pi/prompts/opsx-propose.md                        | 103 ++++++++++++-
 .pi/skills                                         |   1 +
 13 files changed, 570 insertions(+), 8 deletions(-)

diff --git a/.agents/skills b/.agents/skills
new file mode 120000
index 00000000000..2a62b2723e3
--- /dev/null
+++ b/.agents/skills
@@ -0,0 +1 @@
+../.codex/skills
\ No newline at end of file
diff --git a/.pi/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-apply-change/SKILL.md
rename to .codex/skills/openspec-apply-change/SKILL.md
diff --git a/.pi/skills/openspec-archive-change/SKILL.md b/.codex/skills/openspec-archive-change/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-archive-change/SKILL.md
rename to .codex/skills/openspec-archive-change/SKILL.md
diff --git a/.pi/skills/openspec-explore/SKILL.md b/.codex/skills/openspec-explore/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-explore/SKILL.md
rename to .codex/skills/openspec-explore/SKILL.md
diff --git a/.pi/skills/openspec-oneshot/SKILL.md b/.codex/skills/openspec-oneshot/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-oneshot/SKILL.md
rename to .codex/skills/openspec-oneshot/SKILL.md
diff --git a/.pi/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-propose/SKILL.md
rename to .codex/skills/openspec-propose/SKILL.md
diff --git a/.pi/skills/openspec-sync-specs/SKILL.md b/.codex/skills/openspec-sync-specs/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-sync-specs/SKILL.md
rename to .codex/skills/openspec-sync-specs/SKILL.md
diff --git a/.pi/skills/openspec-update-change/SKILL.md b/.codex/skills/openspec-update-change/SKILL.md
similarity index 100%
rename from .pi/skills/openspec-update-change/SKILL.md
rename to .codex/skills/openspec-update-change/SKILL.md
diff --git a/.pi/prompts/opsx-apply.md b/.pi/prompts/opsx-apply.md
index cc3f8160641..d92897f2623 100644
--- a/.pi/prompts/opsx-apply.md
+++ b/.pi/prompts/opsx-apply.md
@@ -1,5 +1,150 @@
 ---
-description: Thin alias for the authoritative openspec-apply-change skill
+description: Implement tasks from an OpenSpec change (Experimental)
 ---
 
-This prompt is a compatibility alias only. The authoritative behavior lives in `.pi/skills/openspec-apply-change/SKILL.md` and should be used instead.
+Implement tasks from an OpenSpec change.
+
+**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+**Provided arguments**: $@
+
+**Steps**
+
+1. **Select the change**
+
+   If a name is provided, use it. Otherwise:
+   - Infer from conversation context if the user mentioned a change
+   - Auto-select if only one active change exists
+   - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
+
+   Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
+
+2. **Check status to understand the schema**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used (e.g., "spec-driven")
+   - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
+
+3. **Get apply instructions**
+
+   ```bash
+   openspec instructions apply --change "<name>" --json
+   ```
+
+   This returns:
+   - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
+   - Progress (total, complete, remaining)
+   - Task list with status
+   - Dynamic instruction based on current state
+
+   **Handle states:**
+   - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
+   - If `state: "all_done"`: congratulate, suggest archive
+   - Otherwise: proceed to implementation
+
+4. **Read context files**
+
+   Read every file path listed under `contextFiles` from the apply instructions output.
+   The files depend on the schema being used:
+   - **spec-driven**: proposal, specs, design, tasks
+   - Other schemas: follow the contextFiles from CLI output
+
+5. **Show current progress**
+
+   Display:
+   - Schema being used
+   - Progress: "N/M tasks complete"
+   - Remaining tasks overview
+   - Dynamic instruction from CLI
+
+6. **Implement tasks (loop until done or blocked)**
+
+   For each pending task:
+   - Show which task is being worked on
+   - Make the code changes required
+   - Keep changes minimal and focused
+   - Mark task complete in the tasks file: `- [ ]` → `- [x]`
+   - Continue to next task
+
+   **Pause if:**
+   - Task is unclear → ask for clarification
+   - Implementation reveals a design issue → suggest updating artifacts
+   - Error or blocker encountered → report and wait for guidance
+   - User interrupts
+
+7. **On completion or pause, show status**
+
+   Display:
+   - Tasks completed this session
+   - Overall progress: "N/M tasks complete"
+   - If all done: suggest archive
+   - If paused: explain why and wait for guidance
+
+**Output During Implementation**
+
+```
+## Implementing: <change-name> (schema: <schema-name>)
+
+Working on task 3/7: <task description>
+[...implementation happening...]
+✓ Task complete
+
+Working on task 4/7: <task description>
+[...implementation happening...]
+✓ Task complete
+```
+
+**Output On Completion**
+
+```
+## Implementation Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 7/7 tasks complete ✓
+
+### Completed This Session
+- [x] Task 1
+- [x] Task 2
+...
+
+All tasks complete! You can archive this change with `/opsx-archive`.
+```
+
+**Output On Pause (Issue Encountered)**
+
+```
+## Implementation Paused
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Progress:** 4/7 tasks complete
+
+### Issue Encountered
+<description of the issue>
+
+**Options:**
+1. <option 1>
+2. <option 2>
+3. Other approach
+
+What would you like to do?
+```
+
+**Guardrails**
+- Keep going through tasks until done or blocked
+- Always read context files before starting (from the apply instructions output)
+- If task is ambiguous, pause and ask before implementing
+- If implementation reveals issues, pause and suggest artifact updates
+- Keep code changes minimal and scoped to each task
+- Update task checkbox immediately after completing each task
+- Pause on errors, blockers, or unclear requirements - don't guess
+- Use contextFiles from CLI output, don't assume specific file names
+
+**Fluid Workflow Integration**
+
+This skill supports the "actions on a change" model:
+
+- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
+- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
diff --git a/.pi/prompts/opsx-archive.md b/.pi/prompts/opsx-archive.md
index d0e48f7d591..245f889474e 100644
--- a/.pi/prompts/opsx-archive.md
+++ b/.pi/prompts/opsx-archive.md
@@ -1,5 +1,155 @@
 ---
-description: Thin alias for the authoritative openspec-archive-change skill
+description: Archive a completed change in the experimental workflow
 ---
 
-This prompt is a compatibility alias only. The authoritative behavior lives in `.pi/skills/openspec-archive-change/SKILL.md` and should be used instead.
+Archive a completed change in the experimental workflow.
+
+**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
+**Provided arguments**: $@
+
+**Steps**
+
+1. **If no change name provided, prompt for selection**
+
+   Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
+
+   Show only active changes (not already archived).
+   Include the schema used for each change if available.
+
+   **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
+
+2. **Check artifact completion status**
+
+   Run `openspec status --change "<name>" --json` to check artifact completion.
+
+   Parse the JSON to understand:
+   - `schemaName`: The workflow being used
+   - `artifacts`: List of artifacts with their status (`done` or other)
+
+   **If any artifacts are not `done`:**
+   - Display warning listing incomplete artifacts
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+3. **Check task completion status**
+
+   Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
+
+   Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
+
+   **If incomplete tasks found:**
+   - Display warning showing count of incomplete tasks
+   - Prompt user for confirmation to continue
+   - Proceed if user confirms
+
+   **If no tasks file exists:** Proceed without task-related warning.
+
+4. **Assess delta spec sync state**
+
+   Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
+
+   **If delta specs exist:**
+   - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
+   - Determine what changes would be applied (adds, modifications, removals, renames)
+   - Show a combined summary before prompting
+
+   **Prompt options:**
+   - If changes needed: "Sync now (recommended)", "Archive without syncing"
+   - If already synced: "Archive now", "Sync anyway", "Cancel"
+
+   If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
+
+5. **Perform the archive**
+
+   Create the archive directory if it doesn't exist:
+   ```bash
+   mkdir -p openspec/changes/archive
+   ```
+
+   Generate target name using current date: `YYYY-MM-DD-<change-name>`
+
+   **Check if target already exists:**
+   - If yes: Fail with error, suggest renaming existing archive or using different date
+   - If no: Move the change directory to archive
+
+   ```bash
+   mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
+   ```
+
+6. **Display summary**
+
+   Show archive completion summary including:
+   - Change name
+   - Schema that was used
+   - Archive location
+   - Spec sync status (synced / sync skipped / no delta specs)
+   - Note about any warnings (incomplete artifacts/tasks)
+
+**Output On Success**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** ✓ Synced to main specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success (No Delta Specs)**
+
+```
+## Archive Complete
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** No delta specs
+
+All artifacts complete. All tasks complete.
+```
+
+**Output On Success With Warnings**
+
+```
+## Archive Complete (with warnings)
+
+**Change:** <change-name>
+**Schema:** <schema-name>
+**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
+**Specs:** Sync skipped (user chose to skip)
+
+**Warnings:**
+- Archived with 2 incomplete artifacts
+- Archived with 3 incomplete tasks
+- Delta spec sync was skipped (user chose to skip)
+
+Review the archive if this was not intentional.
+```
+
+**Output On Error (Archive Exists)**
+
+```
+## Archive Failed
+
+**Change:** <change-name>
+**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
+
+Target archive directory already exists.
+
+**Options:**
+1. Rename the existing archive
+2. Delete the existing archive if it's a duplicate
+3. Wait until a different date to archive
+```
+
+**Guardrails**
+- Always prompt for change selection if not provided
+- Use artifact graph (openspec status --json) for completion checking
+- Don't block archive on warnings - just inform and confirm
+- Preserve .openspec.yaml when moving to archive (it moves with the directory)
+- Show clear summary of what happened
+- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
+- If delta specs exist, always run the sync assessment and show the combined summary before prompting
diff --git a/.pi/prompts/opsx-explore.md b/.pi/prompts/opsx-explore.md
index f097c861e4b..153badb5aae 100644
--- a/.pi/prompts/opsx-explore.md
+++ b/.pi/prompts/opsx-explore.md
@@ -1,5 +1,171 @@
 ---
-description: Thin alias for the authoritative openspec-explore skill
+description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
 ---
 
-This prompt is a compatibility alias only. The authoritative behavior lives in `.pi/skills/openspec-explore/SKILL.md` and should be used instead.
+Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
+
+**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
+
+**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
+
+**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
+**Provided arguments**: $@
+- A vague idea: "real-time collaboration"
+- A specific problem: "the auth system is getting unwieldy"
+- A change name: "add-dark-mode" (to explore in context of that change)
+- A comparison: "postgres vs sqlite for this"
+- Nothing (just enter explore mode)
+
+---
+
+## The Stance
+
+- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
+- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
+- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
+- **Adaptive** - Follow interesting threads, pivot when new information emerges
+- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
+- **Grounded** - Explore the actual codebase when relevant, don't just theorize
+
+---
+
+## What You Might Do
+
+Depending on what the user brings, you might:
+
+**Explore the problem space**
+- Ask clarifying questions that emerge from what they said
+- Challenge assumptions
+- Reframe the problem
+- Find analogies
+
+**Investigate the codebase**
+- Map existing architecture relevant to the discussion
+- Find integration points
+- Identify patterns already in use
+- Surface hidden complexity
+
+**Compare options**
+- Brainstorm multiple approaches
+- Build comparison tables
+- Sketch tradeoffs
+- Recommend a path (if asked)
+
+**Visualize**
+```
+┌─────────────────────────────────────────┐
+│     Use ASCII diagrams liberally        │
+├─────────────────────────────────────────┤
+│                                         │
+│      ┌────────┐         ┌────────┐      │
+│      │ State  │────────▶│ State  │      │
+│      │   A    │         │   B    │      │
+│      └────────┘         └────────┘      │
+│                                         │
+│   System diagrams, state machines,      │
+│   data flows, architecture sketches,    │
+│   dependency graphs, comparison tables  │
+│                                         │
+└─────────────────────────────────────────┘
+```
+
+**Surface risks and unknowns**
+- Identify what could go wrong
+- Find gaps in understanding
+- Suggest spikes or investigations
+
+---
+
+## OpenSpec Awareness
+
+You have full context of the OpenSpec system. Use it naturally, don't force it.
+
+### Check for context
+
+At the start, quickly check what exists:
+```bash
+openspec list --json
+```
+
+This tells you:
+- If there are active changes
+- Their names, schemas, and status
+- What the user might be working on
+
+If the user mentioned a specific change name, read its artifacts for context.
+
+### When no change exists
+
+Think freely. When insights crystallize, you might offer:
+
+- "This feels solid enough to start a change. Want me to create a proposal?"
+- Or keep exploring - no pressure to formalize
+
+### When a change exists
+
+If the user mentions a change or you detect one is relevant:
+
+1. **Read existing artifacts for context**
+   - `openspec/changes/<name>/proposal.md`
+   - `openspec/changes/<name>/design.md`
+   - `openspec/changes/<name>/tasks.md`
+   - etc.
+
+2. **Reference them naturally in conversation**
+   - "Your design mentions using Redis, but we just realized SQLite fits better..."
+   - "The proposal scopes this to premium users, but we're now thinking everyone..."
+
+3. **Offer to capture when decisions are made**
+
+    | Insight Type               | Where to Capture               |
+    |----------------------------|--------------------------------|
+    | New requirement discovered | `specs/<capability>/spec.md` |
+    | Requirement changed        | `specs/<capability>/spec.md` |
+    | Design decision made       | `design.md`                  |
+    | Scope changed              | `proposal.md`                |
+    | New work identified        | `tasks.md`                   |
+    | Assumption invalidated     | Relevant artifact              |
+
+   Example offers:
+   - "That's a design decision. Capture it in design.md?"
+   - "This is a new requirement. Add it to specs?"
+   - "This changes scope. Update the proposal?"
+
+4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
+
+---
+
+## What You Don't Have To Do
+
+- Follow a script
+- Ask the same questions every time
+- Produce a specific artifact
+- Reach a conclusion
+- Stay on topic if a tangent is valuable
+- Be brief (this is thinking time)
+
+---
+
+## Ending Discovery
+
+There's no required ending. Discovery might:
+
+- **Flow into a proposal**: "Ready to start? I can create a change proposal."
+- **Result in artifact updates**: "Updated design.md with these decisions"
+- **Just provide clarity**: User has what they need, moves on
+- **Continue later**: "We can pick this up anytime"
+
+When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
+
+---
+
+## Guardrails
+
+- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
+- **Don't fake understanding** - If something is unclear, dig deeper
+- **Don't rush** - Discovery is thinking time, not task time
+- **Don't force structure** - Let patterns emerge naturally
+- **Don't auto-capture** - Offer to save insights, don't just do it
+- **Do visualize** - A good diagram is worth many paragraphs
+- **Do explore the codebase** - Ground discussions in reality
+- **Do question assumptions** - Including the user's and your own
diff --git a/.pi/prompts/opsx-propose.md b/.pi/prompts/opsx-propose.md
index ddddc4d3c65..60acdffe34c 100644
--- a/.pi/prompts/opsx-propose.md
+++ b/.pi/prompts/opsx-propose.md
@@ -1,5 +1,104 @@
 ---
-description: Thin alias for the authoritative openspec-propose skill
+description: Propose a new change - create it and generate all artifacts in one step
 ---
 
-This prompt is a compatibility alias only. The authoritative behavior lives in `.pi/skills/openspec-propose/SKILL.md` and should be used instead.
+Propose a new change - create the change and generate all artifacts in one step.
+
+I'll create a change with artifacts:
+- proposal.md (what & why)
+- design.md (how)
+- tasks.md (implementation steps)
+
+When ready to implement, run /opsx-apply
+
+---
+
+**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
+**Provided arguments**: $@
+
+**Steps**
+
+1. **If no input provided, ask what they want to build**
+
+   Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
+   > "What change do you want to work on? Describe what you want to build or fix."
+
+   From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
+
+   **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
+
+2. **Create the change directory**
+   ```bash
+   openspec new change "<name>"
+   ```
+   This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
+
+3. **Get the artifact build order**
+   ```bash
+   openspec status --change "<name>" --json
+   ```
+   Parse the JSON to get:
+   - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
+   - `artifacts`: list of all artifacts with their status and dependencies
+
+4. **Create artifacts in sequence until apply-ready**
+
+   Use the **TodoWrite tool** to track progress through the artifacts.
+
+   Loop through artifacts in dependency order (artifacts with no pending dependencies first):
+
+   a. **For each artifact that is `ready` (dependencies satisfied)**:
+      - Get instructions:
+        ```bash
+        openspec instructions <artifact-id> --change "<name>" --json
+        ```
+      - The instructions JSON includes:
+        - `context`: Project background (constraints for you - do NOT include in output)
+        - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
+        - `template`: The structure to use for your output file
+        - `instruction`: Schema-specific guidance for this artifact type
+        - `outputPath`: Where to write the artifact
+        - `dependencies`: Completed artifacts to read for context
+      - Read any completed dependency files for context
+      - Create the artifact file using `template` as the structure
+      - Apply `context` and `rules` as constraints - but do NOT copy them into the file
+      - Show brief progress: "Created <artifact-id>"
+
+   b. **Continue until all `applyRequires` artifacts are complete**
+      - After creating each artifact, re-run `openspec status --change "<name>" --json`
+      - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
+      - Stop when all `applyRequires` artifacts are done
+
+   c. **If an artifact requires user input** (unclear context):
+      - Use **AskUserQuestion tool** to clarify
+      - Then continue with creation
+
+5. **Show final status**
+   ```bash
+   openspec status --change "<name>"
+   ```
+
+**Output**
+
+After completing all artifacts, summarize:
+- Change name and location
+- List of artifacts created with brief descriptions
+- What's ready: "All artifacts created! Ready for implementation."
+- Prompt: "Run `/opsx-apply` to start implementing."
+
+**Artifact Creation Guidelines**
+
+- Follow the `instruction` field from `openspec instructions` for each artifact type
+- The schema defines what each artifact should contain - follow it
+- Read dependency artifacts for context before creating new ones
+- Use `template` as the structure for your output file - fill in its sections
+- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
+  - Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
+  - These guide what you write, but should never appear in the output
+
+**Guardrails**
+- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
+- Always read dependency artifacts before creating a new one
+- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
+- If a change with that name already exists, ask if user wants to continue it or create a new one
+- Verify each artifact file exists after writing before proceeding to next
diff --git a/.pi/skills b/.pi/skills
new file mode 120000
index 00000000000..2a62b2723e3
--- /dev/null
+++ b/.pi/skills
@@ -0,0 +1 @@
+../.codex/skills
\ No newline at end of file


(causeway) 02/15: sorts out skills for .agents and .codex (as well as .pi)

Posted by da...@apache.org.
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 c57c9716fb771849bc0d776a7516916630538c00
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 21:27:08 2026 +0100

    sorts out skills for .agents and .codex (as well as .pi)
---
 .agents/skills                                              | 1 -
 {.codex => .agents}/skills/openspec-apply-change/SKILL.md   | 0
 {.codex => .agents}/skills/openspec-archive-change/SKILL.md | 0
 {.codex => .agents}/skills/openspec-explore/SKILL.md        | 0
 {.codex => .agents}/skills/openspec-oneshot/SKILL.md        | 0
 {.codex => .agents}/skills/openspec-propose/SKILL.md        | 0
 {.codex => .agents}/skills/openspec-sync-specs/SKILL.md     | 0
 {.codex => .agents}/skills/openspec-update-change/SKILL.md  | 0
 .codex/skills                                               | 1 +
 .pi/skills                                                  | 2 +-
 10 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/.agents/skills b/.agents/skills
deleted file mode 120000
index 2a62b2723e3..00000000000
--- a/.agents/skills
+++ /dev/null
@@ -1 +0,0 @@
-../.codex/skills
\ No newline at end of file
diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-apply-change/SKILL.md
rename to .agents/skills/openspec-apply-change/SKILL.md
diff --git a/.codex/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-archive-change/SKILL.md
rename to .agents/skills/openspec-archive-change/SKILL.md
diff --git a/.codex/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-explore/SKILL.md
rename to .agents/skills/openspec-explore/SKILL.md
diff --git a/.codex/skills/openspec-oneshot/SKILL.md b/.agents/skills/openspec-oneshot/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-oneshot/SKILL.md
rename to .agents/skills/openspec-oneshot/SKILL.md
diff --git a/.codex/skills/openspec-propose/SKILL.md b/.agents/skills/openspec-propose/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-propose/SKILL.md
rename to .agents/skills/openspec-propose/SKILL.md
diff --git a/.codex/skills/openspec-sync-specs/SKILL.md b/.agents/skills/openspec-sync-specs/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-sync-specs/SKILL.md
rename to .agents/skills/openspec-sync-specs/SKILL.md
diff --git a/.codex/skills/openspec-update-change/SKILL.md b/.agents/skills/openspec-update-change/SKILL.md
similarity index 100%
rename from .codex/skills/openspec-update-change/SKILL.md
rename to .agents/skills/openspec-update-change/SKILL.md
diff --git a/.codex/skills b/.codex/skills
new file mode 120000
index 00000000000..2b7a412b8fa
--- /dev/null
+++ b/.codex/skills
@@ -0,0 +1 @@
+../.agents/skills
\ No newline at end of file
diff --git a/.pi/skills b/.pi/skills
index 2a62b2723e3..2b7a412b8fa 120000
--- a/.pi/skills
+++ b/.pi/skills
@@ -1 +1 @@
-../.codex/skills
\ No newline at end of file
+../.agents/skills
\ No newline at end of file


(causeway) 15/15: CAUSEWAY-3910: propose command reference data reconciliation

Posted by da...@apache.org.
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 4d9156d2c2459e867d82b7714ee45a9aca7f3ee3
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 08:59:12 2026 +0100

    CAUSEWAY-3910: propose command reference data reconciliation
---
 .../.openspec.yaml                                 |  2 +
 .../reconcile-command-reference-data/design.md     | 70 ++++++++++++++++++++++
 .../reconcile-command-reference-data/proposal.md   | 29 +++++++++
 .../specs/command-export-refdata-marker/spec.md    | 53 ++++++++++++++++
 .../spec.md                                        | 48 +++++++++++++++
 .../reconcile-command-reference-data/tasks.md      | 27 +++++++++
 6 files changed, 229 insertions(+)

diff --git a/openspec/changes/reconcile-command-reference-data/.openspec.yaml b/openspec/changes/reconcile-command-reference-data/.openspec.yaml
new file mode 100644
index 00000000000..84cfc124590
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-06
diff --git a/openspec/changes/reconcile-command-reference-data/design.md b/openspec/changes/reconcile-command-reference-data/design.md
new file mode 100644
index 00000000000..85fc1fbc7b7
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/design.md
@@ -0,0 +1,70 @@
+## Context
+
+P2 established the unified, baseline-bounded command sequence, while R2 will decide whether every target and reference parameter is reachable from export roots, earlier results, or stable reference data. Causeway 4 currently has no reference-data classification contract. The maintenance branch added a bookmark-based SPI, a marker-backed default implementation, and marker declarations on four SecMan domain abstractions.
+
+R1 must establish those reusable contracts without pulling R2 validation into the manager. The public marker belongs in `api/applib` so application domain types and unrelated extensions can opt in without depending on commandlog. The classifier belongs in commandlog applib, which already depends on Causeway's metamodel and can register its default implementation through the existing module import list.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Provide a dependency-neutral marker for domain types whose bookmarked identities are stable across replay environments.
+- Provide a public commandlog SPI that classifies bookmarks and composes any number of implementations using OR semantics.
+- Supply and register a conservative default classifier based only on metamodel type information.
+- Preserve the maintenance branch's built-in SecMan marker declarations.
+- Define focused API, classification, module-registration, and non-loading tests and documentation.
+
+**Non-Goals:**
+
+- Do not calculate known participants, export roots, or baseline-bounded reachability; those belong to R2.
+- Do not change `ReplayableCommand` exportability, command-manager collections, YAML export/import, replay remapping, or workflow actions.
+- Do not infer reference data from persistence annotations, table names, packages, local object resolvability, or fixture conventions.
+- Do not introduce persistent state, a schema migration, or a commandlog JDO adapter.
+
+## Decisions
+
+### Put `RefData` in the public applib domain package
+
+Add the empty marker as `org.apache.causeway.applib.domain.RefData`. Implementing it asserts that a domain type's logical type and bookmark identifiers are well-known and expected to exist in every replay environment. It has no methods and does not itself depend on commandlog.
+
+Keeping the marker in commandlog was rejected because application domain classes and SecMan would then acquire a reverse dependency on an optional extension. An annotation was rejected because the maintenance contract uses assignability and needs no annotation metadata.
+
+### Classify bookmarks through a small commandlog SPI
+
+Add `CommandReplayReferenceDataService` under the commandlog applib SPI package with `boolean isReferenceData(Bookmark)`. Provide a null-safe static composition helper over a list of implementations. A bookmark is reference data when any non-null implementation accepts it; null input, a null list, an empty list, or no accepting implementation produces `false`.
+
+Passing resolved objects was rejected because classification must not require the entity to exist in the source datastore. Selecting one privileged implementation was rejected because applications and extensions can own independent reference-data domains.
+
+### Implement the default classifier with `SpecificationLoader`
+
+Register `CommandReplayReferenceDataServiceForRefData` through `CausewayModuleExtCommandLogApplib`. It asks `SpecificationLoader.specForBookmark` for the bookmark's `ObjectSpecification`, obtains the corresponding Java class, and returns whether `RefData` is assignable from that class. Null bookmarks and unresolved types return `false`; the classifier never resolves or instantiates the bookmarked object.
+
+Using repositories or object loading was rejected because local row existence is not evidence of cross-environment stability. Duplicating logical-type lookup outside the metamodel was rejected because `SpecificationLoader` already owns Causeway 4 bookmark-to-specification resolution.
+
+### Preserve the maintenance SecMan opt-ins
+
+Make `ApplicationUser`, `ApplicationRole`, `ApplicationTenancy`, and `ApplicationPermission` extend `RefData` in their Causeway 4 interface-based forms. This is a source-compatible declaration and adds no fields or persistence behavior. No other framework or application type is inferred to be reference data.
+
+Omitting these declarations was rejected because they are explicit maintenance behavior rather than an R2 reachability choice. Broadening the marker to all SecMan types was rejected because the marker is an explicit stability assertion.
+
+### Keep R1 classification disconnected from export decisions
+
+R1 registers the classifier and tests composition, but does not inject it into `CommandManager`, legacy managers, or `ReplayableCommand`. R2 will combine classification with its export-root and earlier-result predicates once the open export-root decision is resolved.
+
+Integrating the SPI immediately into legacy export validation was rejected because that would partially implement R2 before its reachability model and Causeway 4 service-root adaptation are specified.
+
+## Risks / Trade-offs
+
+- [An application marks a mutable or environment-local type as `RefData`] → Document the stability assertion prominently; R2 can only trust the classification contract and cannot prove cross-environment existence.
+- [A custom classifier throws] → Do not silently convert failures into acceptance or rejection; preserve normal service failure semantics so configuration errors remain visible.
+- [A logical type is unknown during metamodel lookup] → Return `false`, retaining the conservative classification default.
+- [SecMan identities differ between replay environments] → The maintenance behavior intentionally treats their identities as reference data; document that deployments using replay must provision matching identities.
+- [R1 adds API before it affects exportability] → Keep the slice independently tested and explicitly document that R2 is the first consumer of the classification result.
+
+## Migration Plan
+
+Deploy applib, commandlog applib, and SecMan applib changes together. Existing applications remain unclassified unless their domain classes implement `RefData` or they register a custom SPI; no data conversion or bookmark rewrite is required. Rollback removes the marker declarations, classifier, and SPI without datastore repair.
+
+## Open Questions
+
+None blocking for R1. Which services count as Causeway 4 export roots remains an R2 design decision.
diff --git a/openspec/changes/reconcile-command-reference-data/proposal.md b/openspec/changes/reconcile-command-reference-data/proposal.md
new file mode 100644
index 00000000000..6c9dc3d6e27
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/proposal.md
@@ -0,0 +1,29 @@
+## Why
+
+The unified command manager now provides the sequence context required by later export validation, but Causeway 4 has no portable way for applications to identify stable reference-data participants. R1 establishes that classification boundary before R2 uses it for reachability decisions.
+
+## What Changes
+
+- Add a public `RefData` marker in applib for bookmarked domain types whose logical type and identifiers are stable across replay environments.
+- Add a commandlog SPI that classifies a bookmark as replay reference data, supports zero or more application implementations, and combines implementations with OR semantics.
+- Register a default classifier that resolves bookmark types through the Causeway 4 metamodel and recognizes `RefData` implementations without loading bookmarked objects.
+- Mark the maintenance branch's built-in SecMan user, role, tenancy, and permission domain types as reference data.
+- Keep known-participant tracking, reachability validation, export/import behavior, manager workflows, and background gates in their later reconciliation slices.
+
+## Capabilities
+
+### New Capabilities
+
+- `command-export-reference-data-participants`: Defines the public bookmark-classification SPI, multi-service composition, and conservative fallback behavior.
+- `command-export-refdata-marker`: Defines the public marker contract, metamodel-only default classifier, and built-in SecMan reference-data declarations.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- `api/applib`: adds the dependency-neutral public marker contract.
+- `extensions/core/commandlog/applib`: adds and registers the classification SPI and default metamodel classifier with focused tests and reference documentation.
+- `extensions/security/secman/applib`: opts the four maintenance-designated domain abstractions into the marker contract without changing persistence schemas.
+- No command YAML, replay mapping, command-manager memento, datastore, Jakarta Persistence, or commandlog JDO changes are introduced.
diff --git a/openspec/changes/reconcile-command-reference-data/specs/command-export-refdata-marker/spec.md b/openspec/changes/reconcile-command-reference-data/specs/command-export-refdata-marker/spec.md
new file mode 100644
index 00000000000..ee33c901f4c
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/specs/command-export-refdata-marker/spec.md
@@ -0,0 +1,53 @@
+## ADDED Requirements
+
+### Requirement: Public marker declares stable replay reference data
+
+The applib SHALL provide a public, dependency-neutral `RefData` marker interface for domain types whose bookmarked instances are stable replay reference data. The marker SHALL require no methods. Implementing it SHALL assert that the type's logical type and bookmark identifiers are well-known and expected to exist in every replay environment.
+
+#### Scenario: Application domain type opts into reference-data classification
+
+- **WHEN** an application domain type implements `RefData`
+- **THEN** it declares its bookmarked instances to be stable replay reference data without depending on commandlog
+
+### Requirement: Default classifier recognizes marker implementations using the metamodel
+
+The commandlog extension SHALL provide and register a default `CommandReplayReferenceDataService` implementation for `RefData`. The classifier SHALL resolve a non-null bookmark through `SpecificationLoader`, inspect the corresponding class from its `ObjectSpecification`, and return `true` when `RefData` is assignable from that class. It MUST return `false` for null bookmarks, unknown logical types, absent specifications, or non-marker classes, and MUST NOT load or instantiate the bookmar [...]
+
+#### Scenario: Bookmark resolves to a marker type
+
+- **WHEN** a bookmark resolves to an object specification whose corresponding class implements `RefData`
+- **THEN** the default classifier returns `true`
+
+#### Scenario: Bookmark resolves to a non-marker type
+
+- **WHEN** a bookmark resolves to an object specification whose corresponding class does not implement `RefData`
+- **THEN** the default classifier returns `false`
+
+#### Scenario: Bookmark type is unknown
+
+- **WHEN** a bookmark cannot be resolved to an object specification and corresponding class
+- **THEN** the default classifier returns `false`
+
+#### Scenario: Classification uses type information only
+
+- **WHEN** the default classifier evaluates a bookmark
+- **THEN** it consults metamodel type information without loading or instantiating the bookmarked object
+
+#### Scenario: Default and custom classifiers coexist
+
+- **WHEN** the default marker classifier rejects a bookmark and an application classifier accepts it
+- **THEN** composed reference-data classification returns `true`
+
+### Requirement: Built-in SecMan identities are declared as reference data
+
+The SecMan applib `ApplicationUser`, `ApplicationRole`, `ApplicationTenancy`, and `ApplicationPermission` domain abstractions SHALL implement `RefData`. No persistence field or schema change SHALL be required by these marker declarations.
+
+#### Scenario: SecMan domain type is classified by the default service
+
+- **WHEN** a bookmark resolves to any of the four designated SecMan domain abstractions
+- **THEN** the default marker classifier returns `true`
+
+#### Scenario: Marker declarations do not alter persistence
+
+- **WHEN** SecMan domain abstractions implement `RefData`
+- **THEN** their persistent state and datastore schemas remain unchanged
diff --git a/openspec/changes/reconcile-command-reference-data/specs/command-export-reference-data-participants/spec.md b/openspec/changes/reconcile-command-reference-data/specs/command-export-reference-data-participants/spec.md
new file mode 100644
index 00000000000..fe9b7e0ca16
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/specs/command-export-reference-data-participants/spec.md
@@ -0,0 +1,48 @@
+## ADDED Requirements
+
+### Requirement: Applications classify replay reference data through a bookmark SPI
+
+The commandlog extension SHALL provide a public `CommandReplayReferenceDataService` SPI that classifies an entity bookmark as stable replay reference data. The SPI contract SHALL require accepted bookmarks to identify entities whose logical type and identifier are expected to remain stable and to exist in every replay environment. Classification MUST NOT require the bookmark to have appeared in a command sequence.
+
+#### Scenario: Application classifier accepts a bookmark
+
+- **WHEN** an application-provided classifier returns `true` for a bookmark
+- **THEN** the classification result identifies that bookmark as replay reference data
+
+#### Scenario: Application classifier rejects a bookmark
+
+- **WHEN** an application-provided classifier returns `false` for a bookmark
+- **THEN** that classifier does not identify the bookmark as replay reference data
+
+### Requirement: Reference-data classifiers compose using OR semantics
+
+The commandlog extension SHALL provide a reusable classifier composition operation over zero or more `CommandReplayReferenceDataService` implementations. A non-null bookmark SHALL be classified as reference data when at least one non-null implementation returns `true`. It MUST return `false` for a null bookmark, a null or empty service list, or a list in which no implementation accepts the bookmark.
+
+#### Scenario: Later classifier accepts a bookmark
+
+- **WHEN** the default classifier and the first application classifier reject a bookmark but a later application classifier accepts it
+- **THEN** the composed classification returns `true`
+
+#### Scenario: No classifier accepts a bookmark
+
+- **WHEN** every registered classifier rejects a bookmark
+- **THEN** the composed classification returns `false`
+
+#### Scenario: No classifiers are available
+
+- **WHEN** classification is requested with no registered classifiers
+- **THEN** the composed classification returns `false`
+
+#### Scenario: Bookmark is null
+
+- **WHEN** composed classification is requested for a null bookmark
+- **THEN** the composed classification returns `false` without invoking a classifier
+
+### Requirement: R1 classification does not change export reachability
+
+R1 SHALL expose and register reference-data classification independently of command export reachability. R1 MUST NOT make a command exportable, add known participants, or change manager collections solely because a bookmark is classified as reference data; those integrations belong to R2 and later slices.
+
+#### Scenario: Classifier is registered before R2
+
+- **WHEN** R1 classifies a bookmark as reference data
+- **THEN** the current command exportability and manager collection behavior remain unchanged
diff --git a/openspec/changes/reconcile-command-reference-data/tasks.md b/openspec/changes/reconcile-command-reference-data/tasks.md
new file mode 100644
index 00000000000..12e058e4ba5
--- /dev/null
+++ b/openspec/changes/reconcile-command-reference-data/tasks.md
@@ -0,0 +1,27 @@
+## 1. Public Reference-Data Contracts
+
+- [ ] 1.1 Add the dependency-neutral `RefData` marker to applib with API documentation for its cross-environment identity contract.
+- [ ] 1.2 Add the public bookmark-based `CommandReplayReferenceDataService` SPI and null-safe OR-composition helper.
+- [ ] 1.3 Add focused composition tests covering accepted, rejected, multiple-service, null-service, empty-list, and null-bookmark cases, including normal propagation of classifier failures.
+
+## 2. Default Metamodel Classifier
+
+- [ ] 2.1 Implement `CommandReplayReferenceDataServiceForRefData` using `SpecificationLoader` and corresponding-class assignability without object resolution.
+- [ ] 2.2 Register the default classifier in `CausewayModuleExtCommandLogApplib` and add a module-registration test.
+- [ ] 2.3 Add focused tests for marker and non-marker classes, unknown logical types, null bookmarks, custom-classifier composition, and metamodel-only access.
+
+## 3. Built-In SecMan Reference Data
+
+- [ ] 3.1 Make the Causeway 4 `ApplicationUser`, `ApplicationRole`, `ApplicationTenancy`, and `ApplicationPermission` abstractions extend `RefData`.
+- [ ] 3.2 Add focused contract tests confirming exactly the designated SecMan abstractions carry the marker and that no persistence mapping changes are introduced.
+
+## 4. Scope and Documentation
+
+- [ ] 4.1 Document the public marker, custom SPI extension point, OR semantics, conservative fallback, metamodel-only default, and SecMan provisioning responsibility.
+- [ ] 4.2 Add scope guards confirming R1 does not change command exportability, known participants, manager collections, YAML, replay mappings, workflow actions, background gates, datastore schemas, or commandlog JDO support.
+
+## 5. Verification
+
+- [ ] 5.1 Run focused applib, commandlog applib, and SecMan applib tests covering the new contracts and module wiring.
+- [ ] 5.2 Run the affected aggregate Maven verification suite under the repository's supported JDK and resolve compilation, formatting, and inspection failures.
+- [ ] 5.3 Run strict OpenSpec validation and confirm all R1 scenarios are represented by tests or explicit structural checks.


(causeway) 14/15: CAUSEWAY-3910: update reconciliation ledger for P2

Posted by da...@apache.org.
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 43e42014ab054ffeacbd5be3815010f1707a947a
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 08:33:26 2026 +0100

    CAUSEWAY-3910: update reconciliation ledger for P2
---
 openspec/reconciliation/maintenance-branch/ledger.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/openspec/reconciliation/maintenance-branch/ledger.md b/openspec/reconciliation/maintenance-branch/ledger.md
index c4e7a266545..04a3c94f90a 100644
--- a/openspec/reconciliation/maintenance-branch/ledger.md
+++ b/openspec/reconciliation/maintenance-branch/ledger.md
@@ -38,7 +38,7 @@
 | CAUSEWAY-4010, 4039 | M1, M2 | Bookmark-only remapping SPI, execution-copy remapping, atomic result observation, and conditional in-memory listener reconciled | Supersede | Completed by `reconcile-command-replay-mapping` |
 | Later maintenance mapping work | M3 | Persistent mapping contracts, listener, management UI, and Jakarta Persistence adapter reconciled; the removed commandlog JDO adapter is not applicable on Causeway 4 | Supersede | Completed by `reconcile-persistent-replay-mapping` |
 | Maintenance consolidated replayable-command specs | P1 | Replay-useful eligibility, result presence, bookmark participants, actual mappings, object links, identity mementos, layouts, and adjacent navigation reconciled | Supersede | Completed by `reconcile-replayable-command-projection` |
-| Maintenance consolidated manager specs | P2 | Separate export and replay managers exist | Supersede | `reconcile-unified-command-manager` |
+| Maintenance consolidated manager specs | P2 | Unified baseline/limit manager, four review collections, replay-state boundary, primary menu entry, and legacy-manager compatibility shims reconciled | Supersede | Completed by `reconcile-unified-command-manager` |
 | CAUSEWAY-4034 and later maintenance specs | R1 | `RefData` and command replay reference-data SPI absent | Adapt | `reconcile-command-reference-data` |
 | Later maintenance export specs | R2 | Baseline-bounded participant reachability validator absent | Supersede | `reconcile-command-export-reachability` |
 | CAUSEWAY-4010 and later export specs | E1 | Legacy YAML export/import baseline exists | Supersede | `reconcile-command-export-import` |
@@ -56,6 +56,7 @@
 | `reconcile-command-replay-mapping` | D2, M1, M2 | `openspec/specs/command-execution-advisor-policy/spec.md`; `openspec/specs/command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-command-replay-mapping/` | Planning `b9412ee98a7`; implementation `6b4ab64d193`; archive `74ba8b40c0a`; focused and affected aggregate Maven tests passed |
 | `reconcile-persistent-replay-mapping` | M3 | `openspec/specs/persistent-command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/` | Planning `d88eb297388`; implementation `1990363924f`; archive `d95ee405f68`; focused and affected aggregate Maven tests passed |
 | `reconcile-replayable-command-projection` | P1 | `openspec/specs/replayable-command-projection/spec.md` | `openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/` | Planning `2e65bb795a7`; implementation `4bc1b204484`; archive `30b53793cf2`; focused and full commandlog reactor Maven tests plus IDE inspection passed |
+| `reconcile-unified-command-manager` | P2 | `openspec/specs/unified-command-manager/spec.md`; `openspec/specs/replayable-command-projection/spec.md` | `openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/` | Planning `3ee2f7875cb`; implementation `5b95dd3b4a9`; archive `6811dfc6b1f`; focused commandlog applib and JPA Maven verification plus strict OpenSpec validation passed |
 
 ## Resolved questions
 
@@ -69,13 +70,13 @@
 | Replay mapping policy remains entirely in commandlog applib through a bookmark-only listener SPI and result-remapping service; core runtime services implement only general command-execution advisor policy. | D2, M1, M2 | `reconcile-command-replay-mapping/design.md` in the archived change, `ResultRemappingServiceTest`, and `CommandExecutorInteractionAdvisorTest` |
 | Persistent replay-result mappings use the Causeway 4 commandlog JPA module only; the commandlog JDO adapter was deliberately removed and is not restored. | M3 | `reconcile-persistent-replay-mapping/design.md`, `CommandReplayResultMapping_IntegTest`, and the Causeway 4 commandlog module inventory |
 | Replayable-command participants remain a derived read model; actual bookmarks come through the mapping SPI, result mappings remain gated until replay succeeds, and pending-or-failed imported work bypasses general eligibility. | P1 | `reconcile-replayable-command-projection/design.md`, `ReplayableCommandParticipantTest`, and `CommandManagerEligibilityTest` |
+| The unified manager is the forward path with a baseline/limit memento; both legacy manager logical types and timestamp-only mementos remain loadable compatibility shims, their standard launchers are hidden, and stored bookmarks are not rewritten. | P2 | `reconcile-unified-command-manager/design.md`, `CommandManagerCompatibilityTest`, and `CommandLogMenuTest` |
 
 ## Open questions
 
 | Question | Affected nodes | Resolution point |
 |---|---|---|
 | Can the Causeway 4 Spring context become unavailable during `ServiceRegistryDefault.select()` in metamodel disposal? | CAUSEWAY-4002 | Separate lifecycle investigation before core work is declared complete |
-| How will migration from separate `CommandExportManager` and `CommandReplayManager` view-model mementos be handled? | P2 | Unified-manager design |
 | Which services count as export roots in the Causeway 4 service registry and logical-type model? | R2 | Reachability design |
 | Which persistence operations are required to retimestamp commands safely under JPA and JDO? | W1 | Manager-workflow design |
 


(causeway) 04/15: CAUSEWAY-3910: persist command replay result mappings

Posted by da...@apache.org.
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 888bb63fa9c7472ad12151533be45e687d654d70
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 22:14:13 2026 +0100

    CAUSEWAY-3910: persist command replay result mappings
---
 .../config/pages/sections/causeway.extensions.adoc |   3 +-
 .../core/config/CausewayConfiguration.java         |   3 +-
 .../adoc/modules/commandlog/pages/about.adoc       |  30 +++-
 .../applib/CausewayModuleExtCommandLogApplib.java  |   4 +
 .../commandlog/applib/app/CommandLogMenu.java      | 123 +++++++++++++
 .../applib/dom/CommandReplayResultMapping.java     | 178 +++++++++++++++++++
 .../CommandReplayResultMapping.layout.fallback.xml |  53 ++++++
 .../dom/CommandReplayResultMappingRepository.java  |  52 ++++++
 ...mmandReplayResultMappingRepositoryAbstract.java | 103 +++++++++++
 .../mixins/CommandReplayResultMapping_delete.java  |  59 +++++++
 .../CommandReplayMappingListenerPersistent.java    | 108 ++++++++++++
 .../commandlog/applib/app/CommandLogMenuTest.java  | 170 ++++++++++++++++++
 ...dReplayResultMappingRepositoryAbstractTest.java | 147 ++++++++++++++++
 .../applib/dom/CommandReplayResultMappingTest.java |  72 ++++++++
 .../CommandReplayResultMapping_deleteTest.java     |  73 ++++++++
 ...CommandReplayMappingListenerPersistentTest.java | 191 +++++++++++++++++++++
 .../CausewayModuleExtCommandLogPersistenceJpa.java |  12 +-
 .../jpa/dom/CommandReplayResultMapping.java        | 117 +++++++++++++
 .../dom/CommandReplayResultMappingRepository.java  |  48 ++++++
 .../CommandReplayResultMapping_IntegTest.java      | 117 +++++++++++++
 .../reconcile-persistent-replay-mapping/tasks.md   |  40 ++---
 21 files changed, 1678 insertions(+), 25 deletions(-)

diff --git a/core/config/src/main/adoc/modules/config/pages/sections/causeway.extensions.adoc b/core/config/src/main/adoc/modules/config/pages/sections/causeway.extensions.adoc
index f03fc9be6bd..52c1a5b5eb7 100644
--- a/core/config/src/main/adoc/modules/config/pages/sections/causeway.extensions.adoc
+++ b/core/config/src/main/adoc/modules/config/pages/sections/causeway.extensions.adoc
@@ -66,7 +66,8 @@ storage-strategy
 |  IN_MEMORY
 | Selects the built-in replay mapping listener storage strategy.
 `IN_MEMORY` provides instance-scoped mappings when no application listener exists.
-`PERSISTENT` suppresses the in-memory listener; persistent storage is supplied by the later commandlog persistence reconciliation.
+`PERSISTENT` uses the commandlog JPA module to retain identity and changed mappings across application restarts, including the originating command interaction id when available.
+Persistent mode requires a `CommandReplayResultMappingRepository`; without one, no built-in listener is installed.
 Applications can provide a `CommandReplayMappingListener` bean to replace the built-in listener.
 
 
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
index 13ce0d1f013..6fb47b4ad5a 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/CausewayConfiguration.java
@@ -3696,7 +3696,8 @@ public record CommandLog(
             public record ReplayResultMapping(
                 /**
                  * Storage strategy for the built-in listener.
-                 * Persistent storage is implemented by the commandlog persistence extensions.
+                 * Persistent storage requires the commandlog JPA persistence module or an
+                 * application-provided replay-result mapping repository.
                  */
                 @DefaultValue("IN_MEMORY")
                 StorageStrategy storageStrategy,
diff --git a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
index 84af43b0b01..2048d3672f9 100644
--- a/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
+++ b/extensions/core/commandlog/adoc/modules/commandlog/pages/about.adoc
@@ -103,6 +103,35 @@ causeway:
 +
 See xref:refguide:config:sections/causeway.extensions.adoc#causeway.extensions.command-log.publish-policy[causeway.extensions.command-log.publish-policy] configuration property for more details.
 
+=== Persistent Replay-result Mappings
+
+Command replay can observe that an object recorded with one bookmark on the source system was created with another bookmark locally.
+By default these replay-result mappings are held in memory for the lifetime of the application instance.
+To retain mappings across restarts, select persistent storage:
+
+[source,yaml]
+.application.yml
+----
+causeway:
+  extensions:
+    command-log:
+      replay-result-mapping:
+        storage-strategy: PERSISTENT
+        on-conflict-policy: THROW_EXCEPTION
+----
+
+Persistent mapping requires the `causeway-extensions-commandlog-persistence-jpa` dependency and its additive `CommandReplayResultMapping` database table.
+Causeway 4's commandlog persistence adapter is JPA-only; the JDO adapter removed for Causeway 4 is not restored by this feature.
+
+The first observed mapping for a recorded bookmark is authoritative.
+Repeated observations of the same actual bookmark are idempotent and retain the original command interaction id.
+A different later result either fails and rolls back replay (`THROW_EXCEPTION`) or is logged and ignored (`LOG_AND_CONTINUE`).
+Identity mappings are stored as well as changed mappings.
+
+When the repository is available, the commandlog menu provides prototyping actions to list all or changed mappings, find mappings by recorded or actual bookmark, and delete all mappings.
+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.
+
 
 [#menubar-layout-xml]
 === menubar.layout.xml
@@ -299,4 +328,3 @@ include::partial$commandsubscriber-vs-executionsubscriber.adoc[]
 * xref:refguide:applib:index/services/publishing/spi/CommandSubscriber.adoc[] SPI
 * xref:refguide:applib:index/services/publishing/spi/ExecutionSubscriber.adoc[] SPI
 * xref:executionlog:about.adoc[] extension
-
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 52cd1263de1..ef234f03384 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
@@ -35,6 +35,8 @@
 import org.apache.causeway.extensions.commandlog.applib.dom.BackgroundService;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandReplayResultMapping_delete;
 import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_childCommands;
 import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_openResultObject;
 import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_siblingCommands;
@@ -73,6 +75,7 @@
         CommandLogEntry_childCommands.class,
         CommandLogEntry_openResultObject.class,
         CommandLogEntry_siblingCommands.class,
+        CommandReplayResultMapping_delete.class,
         ReplayableCommand_makeExportable.class,
         ReplayableCommand_openCommandLogEntry.class,
         ReplayableCommand_openTarget.class,
@@ -103,6 +106,7 @@
         CommandLogPauseStateListener.class,
         CommandSubscriberForCommandLog.class,
         CommandLogEntry.TableColumnOrderDefault.class,
+        CommandReplayResultMapping.TableColumnOrderDefault.class,
         ResultRemappingService.class,
         CommandReplayMappingListenerInMemory.BeanFactory.class,
 
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
index ce89fe45ebd..f9942e1f1fc 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
@@ -42,12 +42,16 @@
 import org.apache.causeway.applib.annotation.Publishing;
 import org.apache.causeway.applib.annotation.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.applib.services.clock.ClockService;
 import org.apache.causeway.applib.services.factory.FactoryService;
+import org.apache.causeway.applib.services.message.MessageService;
 import org.apache.causeway.applib.value.Blob;
 import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext;
@@ -77,8 +81,10 @@ public static abstract class ActionDomainEvent<T>
             extends CausewayModuleExtCommandLogApplib.ActionDomainEvent<T> { }
 
     private final CommandLogEntryRepository commandLogEntryRepository;
+    private final Optional<CommandReplayResultMappingRepository> commandReplayResultMappingRepository;
     private final ClockService clockService;
     private final ReplayContext replayContext;
+    private final MessageService messageService;
 
     @Action(
             commandPublishing = Publishing.DISABLED,
@@ -153,6 +159,123 @@ public class DomainEvent extends ActionDomainEvent<findAll> { }
         }
     }
 
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = findReplayResultMappings.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.SAFE,
+            typeOf = CommandReplayResultMapping.class)
+    @ActionLayout(cssClassFa = "fa-search", sequence = "52")
+    public class findReplayResultMappings {
+        public class DomainEvent extends ActionDomainEvent<findReplayResultMappings> { }
+
+        @MemberSupport public List<? extends CommandReplayResultMapping> act() {
+            return commandReplayResultMappingRepository
+                    .map(CommandReplayResultMappingRepository::findAll)
+                    .orElseGet(List::of);
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = findChangedReplayResultMappings.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.SAFE,
+            typeOf = CommandReplayResultMapping.class)
+    @ActionLayout(cssClassFa = "fa-search", sequence = "53")
+    public class findChangedReplayResultMappings {
+        public class DomainEvent extends ActionDomainEvent<findChangedReplayResultMappings> { }
+
+        @MemberSupport public List<? extends CommandReplayResultMapping> act() {
+            return commandReplayResultMappingRepository
+                    .map(CommandReplayResultMappingRepository::findChanged)
+                    .orElseGet(List::of);
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = findReplayResultMappingByRecordedBookmark.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.SAFE,
+            typeOf = CommandReplayResultMapping.class)
+    @ActionLayout(cssClassFa = "fa-search", sequence = "54")
+    public class findReplayResultMappingByRecordedBookmark {
+        public class DomainEvent extends ActionDomainEvent<findReplayResultMappingByRecordedBookmark> { }
+
+        @MemberSupport public List<? extends CommandReplayResultMapping> act(
+                @Parameter(optionality = Optionality.MANDATORY)
+                final Bookmark recordedBookmark) {
+            return commandReplayResultMappingRepository
+                    .flatMap(repository -> repository.findByRecordedBookmark(recordedBookmark))
+                    .map(List::of)
+                    .orElseGet(List::of);
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = findReplayResultMappingsByActualBookmark.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.SAFE,
+            typeOf = CommandReplayResultMapping.class)
+    @ActionLayout(cssClassFa = "fa-search", sequence = "55")
+    public class findReplayResultMappingsByActualBookmark {
+        public class DomainEvent extends ActionDomainEvent<findReplayResultMappingsByActualBookmark> { }
+
+        @MemberSupport public List<? extends CommandReplayResultMapping> act(
+                @Parameter(optionality = Optionality.MANDATORY)
+                final Bookmark actualBookmark) {
+            return commandReplayResultMappingRepository
+                    .map(repository -> repository.findByActualBookmark(actualBookmark))
+                    .orElseGet(List::of);
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = deleteReplayResultMappings.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE)
+    @ActionLayout(cssClassFa = "fa-trash", sequence = "56")
+    public class deleteReplayResultMappings {
+        public class DomainEvent extends ActionDomainEvent<deleteReplayResultMappings> { }
+
+        @MemberSupport public void act() {
+            commandReplayResultMappingRepository.ifPresent(repository -> {
+                var count = repository.findAll().size();
+                repository.removeAll();
+                messageService.informUser("Deleted %d command replay result mapping%s"
+                        .formatted(count, count == 1 ? "" : "s"));
+            });
+        }
+
+        @MemberSupport public boolean hideAct() {
+            return commandReplayResultMappingRepository.isEmpty();
+        }
+    }
+
     @Action(
             commandPublishing = Publishing.DISABLED,
             domainEvent = exportManager.DomainEvent.class,
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java
new file mode 100644
index 00000000000..08cb19378fd
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.java
@@ -0,0 +1,178 @@
+/*
+ *  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.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+import jakarta.annotation.Priority;
+import jakarta.inject.Named;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.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.Publishing;
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.applib.mixins.system.HasInteractionId;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
+import org.apache.causeway.applib.services.tablecol.TableColumnOrderForCollectionTypeAbstract;
+import org.apache.causeway.applib.util.TitleBuffer;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Persistent association between a result bookmark recorded on the source system and the
+ * bookmark returned when the command is replayed locally.
+ *
+ * @since 4.0 {@index}
+ */
+@Named(CommandReplayResultMapping.LOGICAL_TYPE_NAME)
+@DomainObject(
+        editing = Editing.DISABLED,
+        entityChangePublishing = Publishing.DISABLED)
+@DomainObjectLayout(
+        cssClassFa = "fa-solid fa-arrow-right-from-bracket",
+        titleUiEvent = CommandReplayResultMapping.TitleUiEvent.class,
+        iconUiEvent = CommandReplayResultMapping.IconUiEvent.class,
+        cssClassUiEvent = CommandReplayResultMapping.CssClassUiEvent.class,
+        layoutUiEvent = CommandReplayResultMapping.LayoutUiEvent.class)
+public interface CommandReplayResultMapping extends CommandRecordingSuppressed {
+
+    String LOGICAL_TYPE_NAME = CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandReplayResultMapping";
+    String SCHEMA = CausewayModuleExtCommandLogApplib.SCHEMA;
+    String TABLE = "CommandReplayResultMapping";
+
+    class TitleUiEvent extends CausewayModuleExtCommandLogApplib.TitleUiEvent<CommandReplayResultMapping> { }
+    class IconUiEvent extends CausewayModuleExtCommandLogApplib.IconUiEvent<CommandReplayResultMapping> { }
+    class CssClassUiEvent extends CausewayModuleExtCommandLogApplib.CssClassUiEvent<CommandReplayResultMapping> { }
+    class LayoutUiEvent extends CausewayModuleExtCommandLogApplib.LayoutUiEvent<CommandReplayResultMapping> { }
+
+    abstract class PropertyDomainEvent<T>
+            extends CausewayModuleExtCommandLogApplib.PropertyDomainEvent<CommandReplayResultMapping, T> { }
+
+    @UtilityClass
+    class Nq {
+        public static final String FIND = LOGICAL_TYPE_NAME + ".find";
+        public static final String FIND_CHANGED = LOGICAL_TYPE_NAME + ".findChanged";
+        public static final String FIND_BY_RECORDED_BOOKMARK = LOGICAL_TYPE_NAME + ".findByRecordedBookmark";
+        public static final String FIND_BY_ACTUAL_BOOKMARK = LOGICAL_TYPE_NAME + ".findByActualBookmark";
+    }
+
+    @ObjectSupport
+    default String title() {
+        return new TitleBuffer()
+                .append(getRecordedBookmark())
+                .append("→")
+                .append(getActualBookmark())
+                .toString();
+    }
+
+    @Programmatic
+    default void init(
+            final Bookmark recordedBookmark,
+            final Bookmark actualBookmark,
+            final @Nullable UUID commandInteractionId) {
+        setRecordedBookmark(recordedBookmark);
+        setActualBookmark(actualBookmark);
+        setCommandInteractionId(commandInteractionId);
+    }
+
+    @Property(domainEvent = RecordedBookmark.DomainEvent.class)
+    @java.lang.annotation.Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface RecordedBookmark {
+        class DomainEvent extends PropertyDomainEvent<Bookmark> { }
+        int MAX_LENGTH = 2000;
+        boolean NULLABLE = false;
+    }
+
+    @RecordedBookmark
+    Bookmark getRecordedBookmark();
+    void setRecordedBookmark(Bookmark recordedBookmark);
+
+    @Property(domainEvent = ActualBookmark.DomainEvent.class)
+    @java.lang.annotation.Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface ActualBookmark {
+        class DomainEvent extends PropertyDomainEvent<Bookmark> { }
+        int MAX_LENGTH = 2000;
+        boolean NULLABLE = false;
+    }
+
+    @ActualBookmark
+    Bookmark getActualBookmark();
+    void setActualBookmark(Bookmark actualBookmark);
+
+    @Property(
+            domainEvent = CommandInteractionId.DomainEvent.class,
+            maxLength = CommandInteractionId.MAX_LENGTH,
+            optionality = Optionality.OPTIONAL)
+    @java.lang.annotation.Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface CommandInteractionId {
+        class DomainEvent extends PropertyDomainEvent<UUID> { }
+        int MAX_LENGTH = HasInteractionId.InteractionId.MAX_LENGTH;
+        boolean NULLABLE = true;
+    }
+
+    @CommandInteractionId
+    @Nullable UUID getCommandInteractionId();
+    void setCommandInteractionId(@Nullable UUID commandInteractionId);
+
+    @Service
+    @Priority(PriorityPrecedence.LATE - 10)
+    class TableColumnOrderDefault
+            extends TableColumnOrderForCollectionTypeAbstract<CommandReplayResultMapping> {
+
+        public TableColumnOrderDefault() {
+            super(CommandReplayResultMapping.class);
+        }
+
+        @Override
+        protected List<String> orderParented(
+                final Object parent,
+                final String collectionId,
+                final List<String> propertyIds) {
+            return ordered();
+        }
+
+        @Override
+        protected List<String> orderStandalone(final List<String> propertyIds) {
+            return ordered();
+        }
+
+        private static List<String> ordered() {
+            return Arrays.asList("recordedBookmark", "actualBookmark", "commandInteractionId");
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.fallback.xml
new file mode 100644
index 00000000000..61aecce00c4
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.fallback.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~        http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<bs:grid xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/links https://causeway.apache.org/applib/layout/links/links.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+         xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+         xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+         xmlns:lnk="https://causeway.apache.org/applib/layout/links"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+    <bs:row>
+        <bs:col span="12" unreferencedActions="true">
+            <cpt:domainObject/>
+            <cpt:action id="delete" position="PANEL"/>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="4">
+            <cpt:fieldSet name="Recorded Bookmark" id="recorded">
+                <cpt:property id="recordedBookmark"/>
+            </cpt:fieldSet>
+        </bs:col>
+        <bs:col span="4">
+            <cpt:fieldSet name="Actual Bookmark" id="actual">
+                <cpt:property id="actualBookmark"/>
+            </cpt:fieldSet>
+        </bs:col>
+        <bs:col span="4">
+            <cpt:fieldSet name="Command" id="command">
+                <cpt:property id="commandInteractionId"/>
+            </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/CommandReplayResultMappingRepository.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepository.java
new file mode 100644
index 00000000000..4776e1d3431
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepository.java
@@ -0,0 +1,52 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+
+/**
+ * Persistence-neutral access to replay-result mappings.
+ *
+ * @since 4.0 {@index}
+ */
+public interface CommandReplayResultMappingRepository {
+
+    Optional<CommandReplayResultMapping> findByRecordedBookmark(Bookmark recordedBookmark);
+
+    List<? extends CommandReplayResultMapping> findByActualBookmark(Bookmark actualBookmark);
+
+    List<? extends CommandReplayResultMapping> findChanged();
+
+    List<? extends CommandReplayResultMapping> findAll();
+
+    CommandReplayResultMapping createAndPersist(
+            Bookmark recordedBookmark,
+            Bookmark actualBookmark,
+            @Nullable UUID commandInteractionId);
+
+    void remove(CommandReplayResultMapping mapping);
+
+    void removeAll();
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java
new file mode 100644
index 00000000000..56c732eec4e
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstract.java
@@ -0,0 +1,103 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Provider;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.query.Query;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.factory.FactoryService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+import org.apache.causeway.commons.internal.base._Casts;
+
+/**
+ * Reusable repository behavior for persistence-specific replay-result mapping entities.
+ *
+ * @since 4.0 {@index}
+ */
+public abstract class CommandReplayResultMappingRepositoryAbstract<C extends CommandReplayResultMapping>
+implements CommandReplayResultMappingRepository {
+
+    @Inject Provider<RepositoryService> repositoryServiceProvider;
+    @Inject FactoryService factoryService;
+
+    private final Class<C> entityClass;
+
+    protected CommandReplayResultMappingRepositoryAbstract(final Class<C> entityClass) {
+        this.entityClass = entityClass;
+    }
+
+    @Override
+    public Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark recordedBookmark) {
+        return _Casts.uncheckedCast(repositoryService().firstMatch(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND_BY_RECORDED_BOOKMARK)
+                        .withParameter("recordedBookmark", recordedBookmark)));
+    }
+
+    @Override
+    public List<? extends CommandReplayResultMapping> findByActualBookmark(final Bookmark actualBookmark) {
+        return _Casts.uncheckedCast(repositoryService().allMatches(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND_BY_ACTUAL_BOOKMARK)
+                        .withParameter("actualBookmark", actualBookmark)));
+    }
+
+    @Override
+    public List<? extends CommandReplayResultMapping> findChanged() {
+        return _Casts.uncheckedCast(repositoryService().allMatches(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND_CHANGED)));
+    }
+
+    @Override
+    public List<? extends CommandReplayResultMapping> findAll() {
+        return _Casts.uncheckedCast(repositoryService().allMatches(
+                Query.named(entityClass, CommandReplayResultMapping.Nq.FIND)));
+    }
+
+    @Override
+    public CommandReplayResultMapping createAndPersist(
+            final Bookmark recordedBookmark,
+            final Bookmark actualBookmark,
+            final @Nullable UUID commandInteractionId) {
+        var mapping = factoryService.detachedEntity(entityClass);
+        mapping.init(recordedBookmark, actualBookmark, commandInteractionId);
+        repositoryService().persistAndFlush(mapping);
+        return mapping;
+    }
+
+    @Override
+    public void remove(final CommandReplayResultMapping mapping) {
+        repositoryService().removeAndFlush(mapping);
+    }
+
+    @Override
+    public void removeAll() {
+        repositoryService().removeAll(entityClass);
+    }
+
+    private RepositoryService repositoryService() {
+        return repositoryServiceProvider.get();
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_delete.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_delete.java
new file mode 100644
index 00000000000..58558e27562
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_delete.java
@@ -0,0 +1,59 @@
+/*
+ *  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.mixins;
+
+import jakarta.inject.Inject;
+
+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;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Deletes one persisted replay-result mapping.
+ *
+ * @since 4.0 {@index}
+ */
+@Action(
+        semantics = SemanticsOf.IDEMPOTENT_ARE_YOU_SURE,
+        commandPublishing = Publishing.DISABLED,
+        domainEvent = CommandReplayResultMapping_delete.DomainEvent.class,
+        executionPublishing = Publishing.DISABLED)
+@ActionLayout(describedAs = "Deletes this replay result mapping (cannot be undone)")
+@RequiredArgsConstructor
+public class CommandReplayResultMapping_delete {
+
+    public static class DomainEvent
+            extends CausewayModuleExtCommandLogApplib.ActionDomainEvent<CommandReplayResultMapping_delete> { }
+
+    private final CommandReplayResultMapping commandReplayResultMapping;
+
+    @Inject CommandReplayResultMappingRepository repository;
+
+    @MemberSupport
+    public void act() {
+        repository.remove(commandReplayResultMapping);
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java
new file mode 100644
index 00000000000..572ccf7b391
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistent.java
@@ -0,0 +1,108 @@
+/*
+ *  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.spi;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.core.config.CausewayConfiguration;
+import org.apache.causeway.core.config.CausewayConfiguration.Extensions.CommandLog.ReplayResultMapping.OnConflictPolicy;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Built-in replay mapping listener backed by commandlog persistence.
+ *
+ * @since 4.0 {@index}
+ */
+@Slf4j
+public final class CommandReplayMappingListenerPersistent implements CommandReplayMappingListener {
+
+    private final CommandReplayResultMappingRepository repository;
+    private final OnConflictPolicy onConflictPolicy;
+
+    public CommandReplayMappingListenerPersistent(
+            final CommandReplayResultMappingRepository repository,
+            final OnConflictPolicy onConflictPolicy) {
+        this.repository = repository;
+        this.onConflictPolicy = onConflictPolicy;
+    }
+
+    @Override
+    public Optional<Bookmark> lookup(final Bookmark recordedBookmark) {
+        return repository.findByRecordedBookmark(recordedBookmark)
+                .map(CommandReplayResultMapping::getActualBookmark);
+    }
+
+    @Override
+    public void onReplayResult(
+            final Bookmark recordedResult,
+            final Bookmark actualResult,
+            final UUID interactionId) {
+        var existing = repository.findByRecordedBookmark(recordedResult);
+        if (existing.isEmpty()) {
+            repository.createAndPersist(recordedResult, actualResult, interactionId);
+            return;
+        }
+
+        var existingActualResult = existing.orElseThrow().getActualBookmark();
+        if (existingActualResult.equals(actualResult)) {
+            return;
+        }
+
+        var message = "Recorded result bookmark '%s' was already mapped to actual bookmark '%s', cannot map to '%s'"
+                .formatted(recordedResult, existingActualResult, actualResult);
+        if (onConflictPolicy == OnConflictPolicy.LOG_AND_CONTINUE) {
+            log.error(message);
+            return;
+        }
+        throw new IllegalStateException(message);
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    public static class BeanFactory {
+
+        @Bean
+        @ConditionalOnBean(CommandReplayResultMappingRepository.class)
+        @ConditionalOnMissingBean(CommandReplayMappingListener.class)
+        @ConditionalOnProperty(
+                prefix = "causeway.extensions.command-log.replay-result-mapping",
+                name = "storage-strategy",
+                havingValue = "PERSISTENT")
+        CommandReplayMappingListener commandReplayMappingListenerPersistent(
+                final CommandReplayResultMappingRepository repository,
+                final CausewayConfiguration causewayConfiguration) {
+            return new CommandReplayMappingListenerPersistent(
+                    repository,
+                    causewayConfiguration.extensions()
+                            .commandLog()
+                            .replayResultMapping()
+                            .onConflictPolicy());
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
new file mode 100644
index 00000000000..560fbe92141
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
@@ -0,0 +1,170 @@
+/*
+ *  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.app;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.clock.ClockService;
+import org.apache.causeway.applib.services.message.MessageService;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class CommandLogMenuTest {
+
+    @Test
+    void finderActionsReturnExpectedMappings() {
+        var repository = new FakeRepository();
+        var identity = repository.createAndPersist(bookmark("1"), bookmark("1"), null);
+        var changed = repository.createAndPersist(bookmark("2"), bookmark("9"), null);
+        var alsoNine = repository.createAndPersist(bookmark("3"), bookmark("9"), null);
+        var menu = menu(Optional.of(repository), mock(MessageService.class));
+
+        assertThat(new ArrayList<Object>(menu.new findReplayResultMappings().act()))
+                .containsExactly(identity, changed, alsoNine);
+        assertThat(new ArrayList<Object>(menu.new findChangedReplayResultMappings().act()))
+                .containsExactly(changed, alsoNine);
+        assertThat(new ArrayList<Object>(menu.new findReplayResultMappingByRecordedBookmark().act(bookmark("2"))))
+                .containsExactly(changed);
+        assertThat(menu.new findReplayResultMappingByRecordedBookmark().act(bookmark("missing"))).isEmpty();
+        assertThat(new ArrayList<Object>(menu.new findReplayResultMappingsByActualBookmark().act(bookmark("9"))))
+                .containsExactly(changed, alsoNine);
+    }
+
+    @Test
+    void mappingActionsHideWithoutRepository() {
+        var menu = menu(Optional.empty(), mock(MessageService.class));
+
+        assertThat(menu.new findReplayResultMappings().hideAct()).isTrue();
+        assertThat(menu.new findChangedReplayResultMappings().hideAct()).isTrue();
+        assertThat(menu.new findReplayResultMappingByRecordedBookmark().hideAct()).isTrue();
+        assertThat(menu.new findReplayResultMappingsByActualBookmark().hideAct()).isTrue();
+        assertThat(menu.new deleteReplayResultMappings().hideAct()).isTrue();
+    }
+
+    @Test
+    void bulkDeleteIsIdempotentAndReportsCount() {
+        var repository = new FakeRepository();
+        repository.createAndPersist(bookmark("1"), bookmark("2"), null);
+        repository.createAndPersist(bookmark("3"), bookmark("4"), null);
+        var messages = mock(MessageService.class);
+        var menu = menu(Optional.of(repository), messages);
+
+        menu.new deleteReplayResultMappings().act();
+
+        assertThat(repository.findAll()).isEmpty();
+        verify(messages).informUser("Deleted 2 command replay result mappings");
+
+        menu.new deleteReplayResultMappings().act();
+        verify(messages).informUser("Deleted 0 command replay result mappings");
+        assertThat(CommandLogMenu.deleteReplayResultMappings.class.getAnnotation(Action.class).semantics())
+                .isEqualTo(SemanticsOf.IDEMPOTENT_ARE_YOU_SURE);
+    }
+
+    @Test
+    void mappingActionsFollowReplayWorkflowOrder() {
+        assertThat(sequenceOf(CommandLogMenu.exportManager.class))
+                .isLessThan(sequenceOf(CommandLogMenu.replayManager.class));
+        assertThat(sequenceOf(CommandLogMenu.replayManager.class))
+                .isLessThan(sequenceOf(CommandLogMenu.findReplayResultMappings.class));
+        assertThat(sequenceOf(CommandLogMenu.findReplayResultMappings.class))
+                .isLessThan(sequenceOf(CommandLogMenu.findChangedReplayResultMappings.class));
+        assertThat(sequenceOf(CommandLogMenu.findChangedReplayResultMappings.class))
+                .isLessThan(sequenceOf(CommandLogMenu.findReplayResultMappingByRecordedBookmark.class));
+        assertThat(sequenceOf(CommandLogMenu.findReplayResultMappingByRecordedBookmark.class))
+                .isLessThan(sequenceOf(CommandLogMenu.findReplayResultMappingsByActualBookmark.class));
+        assertThat(sequenceOf(CommandLogMenu.findReplayResultMappingsByActualBookmark.class))
+                .isLessThan(sequenceOf(CommandLogMenu.deleteReplayResultMappings.class));
+    }
+
+    private static int sequenceOf(final Class<?> actionClass) {
+        return Integer.parseInt(actionClass.getAnnotation(ActionLayout.class).sequence());
+    }
+
+    private static CommandLogMenu menu(
+            final Optional<CommandReplayResultMappingRepository> repository,
+            final MessageService messages) {
+        return new CommandLogMenu(
+                mock(CommandLogEntryRepository.class),
+                repository,
+                mock(ClockService.class),
+                null,
+                messages);
+    }
+
+    private static Bookmark bookmark(final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo.Invoice", id);
+    }
+
+    static final class FakeRepository implements CommandReplayResultMappingRepository {
+        private final Map<Bookmark, Mapping> mappings = new LinkedHashMap<>();
+
+        @Override public Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark bookmark) {
+            return Optional.ofNullable(mappings.get(bookmark));
+        }
+        @Override public List<? extends CommandReplayResultMapping> findByActualBookmark(final Bookmark bookmark) {
+            return mappings.values().stream().filter(mapping -> mapping.getActualBookmark().equals(bookmark)).toList();
+        }
+        @Override public List<? extends CommandReplayResultMapping> findChanged() {
+            return mappings.values().stream()
+                    .filter(mapping -> !mapping.getRecordedBookmark().equals(mapping.getActualBookmark())).toList();
+        }
+        @Override public List<? extends CommandReplayResultMapping> findAll() { return List.copyOf(mappings.values()); }
+        @Override public CommandReplayResultMapping createAndPersist(
+                final Bookmark recorded, final Bookmark actual, final @Nullable UUID interactionId) {
+            var mapping = new Mapping();
+            mapping.init(recorded, actual, interactionId);
+            mappings.put(recorded, mapping);
+            return mapping;
+        }
+        @Override public void remove(final CommandReplayResultMapping mapping) {
+            mappings.remove(mapping.getRecordedBookmark());
+        }
+        @Override public void removeAll() { mappings.clear(); }
+    }
+
+    static final class Mapping implements CommandReplayResultMapping {
+        private Bookmark recordedBookmark;
+        private Bookmark actualBookmark;
+        private @Nullable UUID commandInteractionId;
+        @Override public Bookmark getRecordedBookmark() { return recordedBookmark; }
+        @Override public void setRecordedBookmark(final Bookmark value) { recordedBookmark = value; }
+        @Override public Bookmark getActualBookmark() { return actualBookmark; }
+        @Override public void setActualBookmark(final Bookmark value) { actualBookmark = value; }
+        @Override public @Nullable UUID getCommandInteractionId() { return commandInteractionId; }
+        @Override public void setCommandInteractionId(final @Nullable UUID value) { commandInteractionId = value; }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstractTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstractTest.java
new file mode 100644
index 00000000000..7799e01c73d
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingRepositoryAbstractTest.java
@@ -0,0 +1,147 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import jakarta.inject.Provider;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.query.NamedQuery;
+import org.apache.causeway.applib.query.Query;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.factory.FactoryService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+
+import 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 CommandReplayResultMappingRepositoryAbstractTest {
+
+    private final RepositoryService repositoryService = mock(RepositoryService.class);
+    private final FactoryService factoryService = mock(FactoryService.class);
+    private final Repository repository = new Repository();
+
+    @BeforeEach
+    void setUp() {
+        repository.repositoryServiceProvider = providerOf(repositoryService);
+        repository.factoryService = factoryService;
+    }
+
+    @Test
+    void selectsNamedQueriesAndParameters() {
+        when(repositoryService.firstMatch(any(Query.class))).thenReturn(Optional.empty());
+        when(repositoryService.allMatches(any(Query.class))).thenReturn(List.of());
+        var recorded = bookmark("1");
+        var actual = bookmark("2");
+
+        repository.findByRecordedBookmark(recorded);
+        var firstQuery = captureFirstMatchQuery();
+        assertThat(firstQuery.getName()).isEqualTo(CommandReplayResultMapping.Nq.FIND_BY_RECORDED_BOOKMARK);
+        assertThat(firstQuery.getParametersByName()).containsEntry("recordedBookmark", recorded);
+
+        repository.findByActualBookmark(actual);
+        var actualQuery = captureLastAllMatchesQuery();
+        assertThat(actualQuery.getName()).isEqualTo(CommandReplayResultMapping.Nq.FIND_BY_ACTUAL_BOOKMARK);
+        assertThat(actualQuery.getParametersByName()).containsEntry("actualBookmark", actual);
+
+        repository.findChanged();
+        assertThat(captureLastAllMatchesQuery().getName()).isEqualTo(CommandReplayResultMapping.Nq.FIND_CHANGED);
+
+        repository.findAll();
+        assertThat(captureLastAllMatchesQuery().getName()).isEqualTo(CommandReplayResultMapping.Nq.FIND);
+    }
+
+    @Test
+    void createsInitializesAndFlushesMapping() {
+        var mapping = new Mapping();
+        var interactionId = UUID.randomUUID();
+        when(factoryService.detachedEntity(Mapping.class)).thenReturn(mapping);
+
+        var result = repository.createAndPersist(bookmark("1"), bookmark("2"), interactionId);
+
+        assertThat(result).isSameAs(mapping);
+        assertThat(mapping.getRecordedBookmark()).isEqualTo(bookmark("1"));
+        assertThat(mapping.getActualBookmark()).isEqualTo(bookmark("2"));
+        assertThat(mapping.getCommandInteractionId()).isEqualTo(interactionId);
+        verify(repositoryService).persistAndFlush(mapping);
+    }
+
+    @Test
+    void removesOneOrAllMappings() {
+        var mapping = new Mapping();
+
+        repository.remove(mapping);
+        repository.removeAll();
+
+        verify(repositoryService).removeAndFlush(mapping);
+        verify(repositoryService).removeAll(Mapping.class);
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private NamedQuery<Mapping> captureFirstMatchQuery() {
+        var captor = ArgumentCaptor.forClass(Query.class);
+        verify(repositoryService).firstMatch(captor.capture());
+        return (NamedQuery<Mapping>) captor.getValue();
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    private NamedQuery<Mapping> captureLastAllMatchesQuery() {
+        var captor = ArgumentCaptor.forClass(Query.class);
+        verify(repositoryService, org.mockito.Mockito.atLeastOnce()).allMatches(captor.capture());
+        var values = captor.getAllValues();
+        return (NamedQuery<Mapping>) values.get(values.size() - 1);
+    }
+
+    private static Provider<RepositoryService> providerOf(final RepositoryService service) {
+        return () -> service;
+    }
+
+    private static Bookmark bookmark(final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo.Invoice", id);
+    }
+
+    private static final class Repository
+            extends CommandReplayResultMappingRepositoryAbstract<Mapping> {
+        private Repository() { super(Mapping.class); }
+    }
+
+    private static final class Mapping implements CommandReplayResultMapping {
+        private Bookmark recordedBookmark;
+        private Bookmark actualBookmark;
+        private @Nullable UUID commandInteractionId;
+
+        @Override public Bookmark getRecordedBookmark() { return recordedBookmark; }
+        @Override public void setRecordedBookmark(final Bookmark value) { recordedBookmark = value; }
+        @Override public Bookmark getActualBookmark() { return actualBookmark; }
+        @Override public void setActualBookmark(final Bookmark value) { actualBookmark = value; }
+        @Override public @Nullable UUID getCommandInteractionId() { return commandInteractionId; }
+        @Override public void setCommandInteractionId(final @Nullable UUID value) { commandInteractionId = value; }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingTest.java
new file mode 100644
index 00000000000..edd660b32e9
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMappingTest.java
@@ -0,0 +1,72 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CommandReplayResultMappingTest {
+
+    @Test
+    void initializesAndTitlesMapping() {
+        var mapping = new Mapping();
+        var interactionId = UUID.randomUUID();
+
+        mapping.init(bookmark("1"), bookmark("2"), interactionId);
+
+        assertThat(mapping.getRecordedBookmark()).isEqualTo(bookmark("1"));
+        assertThat(mapping.getActualBookmark()).isEqualTo(bookmark("2"));
+        assertThat(mapping.getCommandInteractionId()).isEqualTo(interactionId);
+        assertThat(mapping.title()).isEqualTo("demo.Invoice:1 → demo.Invoice:2");
+    }
+
+    @Test
+    void permitsMissingInteractionId() {
+        var mapping = new Mapping();
+
+        mapping.init(bookmark("1"), bookmark("1"), null);
+
+        assertThat(mapping.getCommandInteractionId()).isNull();
+        assertThat(mapping.title()).isEqualTo("demo.Invoice:1 → demo.Invoice:1");
+    }
+
+    private static Bookmark bookmark(final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo.Invoice", id);
+    }
+
+    private static final class Mapping implements CommandReplayResultMapping {
+        private Bookmark recordedBookmark;
+        private Bookmark actualBookmark;
+        private @Nullable UUID commandInteractionId;
+
+        @Override public Bookmark getRecordedBookmark() { return recordedBookmark; }
+        @Override public void setRecordedBookmark(final Bookmark value) { recordedBookmark = value; }
+        @Override public Bookmark getActualBookmark() { return actualBookmark; }
+        @Override public void setActualBookmark(final Bookmark value) { actualBookmark = value; }
+        @Override public @Nullable UUID getCommandInteractionId() { return commandInteractionId; }
+        @Override public void setCommandInteractionId(final @Nullable UUID value) { commandInteractionId = value; }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_deleteTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_deleteTest.java
new file mode 100644
index 00000000000..7b3295c645c
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/mixins/CommandReplayResultMapping_deleteTest.java
@@ -0,0 +1,73 @@
+/*
+ *  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.mixins;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.context.annotation.Import;
+
+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.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class CommandReplayResultMapping_deleteTest {
+
+    @Test
+    void actionRequiresConfirmationAndRemovesOnlyItsMapping() {
+        var mapping = mock(CommandReplayResultMapping.class);
+        var repository = mock(CommandReplayResultMappingRepository.class);
+        var mixin = new CommandReplayResultMapping_delete(mapping);
+        mixin.repository = repository;
+
+        mixin.act();
+
+        verify(repository).remove(mapping);
+        var action = CommandReplayResultMapping_delete.class.getAnnotation(Action.class);
+        assertThat(action.semantics()).isEqualTo(SemanticsOf.IDEMPOTENT_ARE_YOU_SURE);
+        assertThat(action.commandPublishing()).isEqualTo(Publishing.DISABLED);
+        assertThat(action.executionPublishing()).isEqualTo(Publishing.DISABLED);
+    }
+
+    @Test
+    void moduleAndLayoutExposeDeleteAndAuditFields() throws Exception {
+        var imports = CausewayModuleExtCommandLogApplib.class.getAnnotation(Import.class);
+        assertThat(Arrays.asList(imports.value())).contains(
+                CommandReplayResultMapping_delete.class,
+                CommandReplayResultMapping.TableColumnOrderDefault.class);
+
+        try (var stream = getClass().getResourceAsStream(
+                "/org/apache/causeway/extensions/commandlog/applib/dom/CommandReplayResultMapping.layout.fallback.xml")) {
+            assertThat(stream).isNotNull();
+            var layout = new String(stream.readAllBytes());
+            assertThat(layout).contains("<cpt:action id=\"delete\" position=\"PANEL\"/>");
+            assertThat(layout).contains("<cpt:property id=\"recordedBookmark\"/>");
+            assertThat(layout).contains("<cpt:property id=\"actualBookmark\"/>");
+            assertThat(layout).contains("<cpt:property id=\"commandInteractionId\"/>");
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
new file mode 100644
index 00000000000..109e54f0bea
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/spi/CommandReplayMappingListenerPersistentTest.java
@@ -0,0 +1,191 @@
+/*
+ *  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.spi;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.core.config.CausewayConfiguration.Extensions.CommandLog.ReplayResultMapping.OnConflictPolicy;
+import org.apache.causeway.core.config.CausewayModuleCoreConfig;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class CommandReplayMappingListenerPersistentTest {
+
+    @Test
+    void recordsIdentityAndChangedMappingsAndLooksThemUp() {
+        var repository = new InMemoryRepository();
+        var listener = new CommandReplayMappingListenerPersistent(repository, OnConflictPolicy.THROW_EXCEPTION);
+        var interactionId = UUID.randomUUID();
+
+        listener.onReplayResult(bookmark("1"), bookmark("1"), interactionId);
+        listener.onReplayResult(bookmark("2"), bookmark("3"), null);
+
+        assertThat(listener.lookup(bookmark("1"))).contains(bookmark("1"));
+        assertThat(listener.lookup(bookmark("2"))).contains(bookmark("3"));
+        assertThat(listener.lookup(bookmark("missing"))).isEmpty();
+        assertThat(repository.findByRecordedBookmark(bookmark("1")).orElseThrow().getCommandInteractionId())
+                .isEqualTo(interactionId);
+    }
+
+    @Test
+    void repeatedMappingRetainsFirstInteractionId() {
+        var repository = new InMemoryRepository();
+        var listener = new CommandReplayMappingListenerPersistent(repository, OnConflictPolicy.THROW_EXCEPTION);
+        var firstInteractionId = UUID.randomUUID();
+        listener.onReplayResult(bookmark("1"), bookmark("2"), firstInteractionId);
+
+        listener.onReplayResult(bookmark("1"), bookmark("2"), UUID.randomUUID());
+
+        assertThat(repository.findAll()).hasSize(1);
+        assertThat(repository.findByRecordedBookmark(bookmark("1")).orElseThrow().getCommandInteractionId())
+                .isEqualTo(firstInteractionId);
+    }
+
+    @Test
+    void conflictingMappingThrowsOrLogsWithoutReplacingFirst() {
+        var strictRepository = new InMemoryRepository();
+        var strict = new CommandReplayMappingListenerPersistent(strictRepository, OnConflictPolicy.THROW_EXCEPTION);
+        strict.onReplayResult(bookmark("1"), bookmark("2"), null);
+
+        assertThatThrownBy(() -> strict.onReplayResult(bookmark("1"), bookmark("3"), null))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("demo.Invoice:1")
+                .hasMessageContaining("demo.Invoice:2")
+                .hasMessageContaining("demo.Invoice:3");
+        assertThat(strict.lookup(bookmark("1"))).contains(bookmark("2"));
+
+        var lenientRepository = new InMemoryRepository();
+        var lenient = new CommandReplayMappingListenerPersistent(lenientRepository, OnConflictPolicy.LOG_AND_CONTINUE);
+        lenient.onReplayResult(bookmark("1"), bookmark("2"), null);
+        lenient.onReplayResult(bookmark("1"), bookmark("3"), null);
+        assertThat(lenient.lookup(bookmark("1"))).contains(bookmark("2"));
+    }
+
+    @Test
+    void autoconfigurationSelectsPersistentAndBacksOffAsRequired() {
+        contextRunner()
+                .withUserConfiguration(
+                        RepositoryConfiguration.class,
+                        CommandReplayMappingListenerPersistent.BeanFactory.class,
+                        CommandReplayMappingListenerInMemory.BeanFactory.class)
+                .withPropertyValues("causeway.extensions.command-log.replay-result-mapping.storage-strategy=PERSISTENT")
+                .run(context -> {
+                    assertThat(context).hasSingleBean(CommandReplayMappingListener.class);
+                    assertThat(context.getBean(CommandReplayMappingListener.class))
+                            .isInstanceOf(CommandReplayMappingListenerPersistent.class);
+                });
+
+        contextRunner()
+                .withUserConfiguration(CommandReplayMappingListenerPersistent.BeanFactory.class)
+                .withPropertyValues("causeway.extensions.command-log.replay-result-mapping.storage-strategy=PERSISTENT")
+                .run(context -> assertThat(context).doesNotHaveBean(CommandReplayMappingListener.class));
+
+        contextRunner()
+                .withUserConfiguration(
+                        RepositoryConfiguration.class,
+                        CustomListenerConfiguration.class,
+                        CommandReplayMappingListenerPersistent.BeanFactory.class)
+                .withPropertyValues("causeway.extensions.command-log.replay-result-mapping.storage-strategy=PERSISTENT")
+                .run(context -> assertThat(context.getBean(CommandReplayMappingListener.class))
+                        .isSameAs(CustomListenerConfiguration.CUSTOM_LISTENER));
+
+        contextRunner()
+                .withUserConfiguration(
+                        RepositoryConfiguration.class,
+                        CommandReplayMappingListenerPersistent.BeanFactory.class)
+                .withPropertyValues("causeway.extensions.command-log.replay-result-mapping.storage-strategy=IN_MEMORY")
+                .run(context -> assertThat(context).doesNotHaveBean(CommandReplayMappingListener.class));
+    }
+
+    private static ApplicationContextRunner contextRunner() {
+        return new ApplicationContextRunner().withUserConfiguration(CausewayModuleCoreConfig.class);
+    }
+
+    private static Bookmark bookmark(final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo.Invoice", id);
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    static class RepositoryConfiguration {
+        @Bean CommandReplayResultMappingRepository commandReplayResultMappingRepository() {
+            return new InMemoryRepository();
+        }
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    static class CustomListenerConfiguration {
+        static final CommandReplayMappingListener CUSTOM_LISTENER = new CommandReplayMappingListener() { };
+        @Bean CommandReplayMappingListener customCommandReplayMappingListener() { return CUSTOM_LISTENER; }
+    }
+
+    private static final class InMemoryRepository implements CommandReplayResultMappingRepository {
+        private final Map<Bookmark, Mapping> mappings = new LinkedHashMap<>();
+
+        @Override public Optional<CommandReplayResultMapping> findByRecordedBookmark(final Bookmark bookmark) {
+            return Optional.ofNullable(mappings.get(bookmark));
+        }
+        @Override public List<? extends CommandReplayResultMapping> findByActualBookmark(final Bookmark bookmark) {
+            return mappings.values().stream().filter(mapping -> mapping.getActualBookmark().equals(bookmark)).toList();
+        }
+        @Override public List<? extends CommandReplayResultMapping> findChanged() {
+            return mappings.values().stream()
+                    .filter(mapping -> !mapping.getRecordedBookmark().equals(mapping.getActualBookmark())).toList();
+        }
+        @Override public List<? extends CommandReplayResultMapping> findAll() { return List.copyOf(mappings.values()); }
+        @Override public CommandReplayResultMapping createAndPersist(
+                final Bookmark recorded, final Bookmark actual, final @Nullable UUID interactionId) {
+            var mapping = new Mapping();
+            mapping.init(recorded, actual, interactionId);
+            mappings.put(recorded, mapping);
+            return mapping;
+        }
+        @Override public void remove(final CommandReplayResultMapping mapping) {
+            mappings.remove(mapping.getRecordedBookmark());
+        }
+        @Override public void removeAll() { mappings.clear(); }
+    }
+
+    private static final class Mapping implements CommandReplayResultMapping {
+        private Bookmark recordedBookmark;
+        private Bookmark actualBookmark;
+        private @Nullable UUID commandInteractionId;
+        @Override public Bookmark getRecordedBookmark() { return recordedBookmark; }
+        @Override public void setRecordedBookmark(final Bookmark value) { recordedBookmark = value; }
+        @Override public Bookmark getActualBookmark() { return actualBookmark; }
+        @Override public void setActualBookmark(final Bookmark value) { actualBookmark = value; }
+        @Override public @Nullable UUID getCommandInteractionId() { return commandInteractionId; }
+        @Override public void setCommandInteractionId(final @Nullable UUID value) { commandInteractionId = value; }
+    }
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
index e0495a6ef5e..7855d8032d2 100644
--- a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/CausewayModuleExtCommandLogPersistenceJpa.java
@@ -26,6 +26,9 @@
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntry;
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntryPK;
 import org.apache.causeway.extensions.commandlog.jpa.dom.CommandLogEntryRepository;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerPersistent;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandReplayResultMapping;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.persistence.jpa.eclipselink.CausewayModulePersistenceJpaEclipselink;
 import org.apache.causeway.testing.fixtures.applib.CausewayModuleTestingFixturesApplib;
 import org.apache.causeway.testing.fixtures.applib.fixturescripts.FixtureScript;
@@ -45,12 +48,16 @@
         // @Service's
         CommandLogEntryRepository.class,
         CommandLogEntryPK.Semantics.class,
+        CommandReplayResultMappingRepository.class,
+        CommandReplayMappingListenerPersistent.BeanFactory.class,
 
         // entities
-        CommandLogEntry.class
+        CommandLogEntry.class,
+        CommandReplayResultMapping.class
 })
 @EntityScan(basePackageClasses = {
-    CommandLogEntry.class
+    CommandLogEntry.class,
+    CommandReplayResultMapping.class
 })
 public class CausewayModuleExtCommandLogPersistenceJpa {
 
@@ -65,6 +72,7 @@ public FixtureScript teardownFixture() {
         return new TeardownFixtureJpaAbstract() {
             @Override
             protected void execute(final ExecutionContext executionContext) {
+                deleteFrom(CommandReplayResultMapping.class);
                 deleteFrom(CommandLogEntry.class);
             }
         };
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java
new file mode 100644
index 00000000000..2f4cf951f3e
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMapping.java
@@ -0,0 +1,117 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.jpa.dom;
+
+import java.util.UUID;
+
+import jakarta.inject.Named;
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.NamedQueries;
+import jakarta.persistence.NamedQuery;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.Publishing;
+import org.apache.causeway.applib.jaxb.PersistentEntityAdapter;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.Nq;
+import org.apache.causeway.persistence.jpa.applib.integration.CausewayEntityListener;
+import org.apache.causeway.persistence.jpa.integration.typeconverters.applib.CausewayBookmarkConverter;
+import org.apache.causeway.persistence.jpa.integration.typeconverters.java.util.JavaUtilUuidConverter;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(
+        schema = CommandReplayResultMapping.SCHEMA,
+        name = CommandReplayResultMapping.TABLE,
+        indexes = {
+                @Index(
+                        name = "CommandReplayResultMapping__recordedBookmark__IDX",
+                        columnList = "recordedBookmark"),
+                @Index(
+                        name = "CommandReplayResultMapping__actualBookmark__IDX",
+                        columnList = "actualBookmark")
+        },
+        uniqueConstraints = @UniqueConstraint(
+                name = "CommandReplayResultMapping__recordedBookmark__UNQ",
+                columnNames = "recordedBookmark"))
+@NamedQueries({
+        @NamedQuery(
+                name = Nq.FIND,
+                query = "SELECT m FROM CommandReplayResultMapping m ORDER BY m.id"),
+        @NamedQuery(
+                name = Nq.FIND_CHANGED,
+                query = "SELECT m FROM CommandReplayResultMapping m "
+                      + "WHERE m.recordedBookmark <> m.actualBookmark ORDER BY m.id"),
+        @NamedQuery(
+                name = Nq.FIND_BY_RECORDED_BOOKMARK,
+                query = "SELECT m FROM CommandReplayResultMapping m "
+                      + "WHERE m.recordedBookmark = :recordedBookmark"),
+        @NamedQuery(
+                name = Nq.FIND_BY_ACTUAL_BOOKMARK,
+                query = "SELECT m FROM CommandReplayResultMapping m "
+                      + "WHERE m.actualBookmark = :actualBookmark ORDER BY m.id")
+})
+@Named(org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping.LOGICAL_TYPE_NAME)
+@DomainObject(
+        editing = Editing.DISABLED,
+        entityChangePublishing = Publishing.DISABLED)
+@XmlJavaTypeAdapter(PersistentEntityAdapter.class)
+@EntityListeners(CausewayEntityListener.class)
+@NoArgsConstructor
+public class CommandReplayResultMapping
+implements org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping {
+
+    @Id
+    @GeneratedValue
+    @Getter @Setter
+    private Long id;
+
+    @Convert(converter = CausewayBookmarkConverter.class)
+    @Column(nullable = RecordedBookmark.NULLABLE, length = RecordedBookmark.MAX_LENGTH)
+    @RecordedBookmark
+    @Getter @Setter
+    private Bookmark recordedBookmark;
+
+    @Convert(converter = CausewayBookmarkConverter.class)
+    @Column(nullable = ActualBookmark.NULLABLE, length = ActualBookmark.MAX_LENGTH)
+    @ActualBookmark
+    @Getter @Setter
+    private Bookmark actualBookmark;
+
+    @Convert(converter = JavaUtilUuidConverter.class)
+    @Column(nullable = CommandInteractionId.NULLABLE, length = CommandInteractionId.MAX_LENGTH)
+    @CommandInteractionId
+    @Getter @Setter
+    private @Nullable UUID commandInteractionId;
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.java b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.java
new file mode 100644
index 00000000000..1c078911cda
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/main/java/org/apache/causeway/extensions/commandlog/jpa/dom/CommandReplayResultMappingRepository.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.jpa.dom;
+
+import jakarta.inject.Named;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepositoryAbstract;
+import org.apache.causeway.extensions.commandlog.jpa.CausewayModuleExtCommandLogPersistenceJpa;
+
+/**
+ * JPA repository for persistent replay-result mappings.
+ *
+ * @since 4.0 {@index}
+ */
+@Service
+@Named(CommandReplayResultMappingRepository.LOGICAL_TYPE_NAME)
+@jakarta.annotation.Priority(PriorityPrecedence.MIDPOINT)
+@Qualifier("Jpa")
+public class CommandReplayResultMappingRepository
+extends CommandReplayResultMappingRepositoryAbstract<CommandReplayResultMapping> {
+
+    public static final String LOGICAL_TYPE_NAME =
+            CausewayModuleExtCommandLogPersistenceJpa.NAMESPACE + ".CommandReplayResultMappingRepository";
+
+    public CommandReplayResultMappingRepository() {
+        super(CommandReplayResultMapping.class);
+    }
+}
diff --git a/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandReplayResultMapping_IntegTest.java b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandReplayResultMapping_IntegTest.java
new file mode 100644
index 00000000000..7b936cdf99e
--- /dev/null
+++ b/extensions/core/commandlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/commandlog/jpa/integtests/CommandReplayResultMapping_IntegTest.java
@@ -0,0 +1,117 @@
+/*
+ *  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.util.ArrayList;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.persistence.Table;
+
+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.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListener;
+import org.apache.causeway.extensions.commandlog.applib.spi.CommandReplayMappingListenerPersistent;
+import org.apache.causeway.extensions.commandlog.jpa.dom.CommandReplayResultMapping;
+import org.apache.causeway.testing.integtestsupport.applib.CausewayIntegrationTestAbstract;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(
+        classes = AppManifest.class,
+        properties = "causeway.extensions.command-log.replay-result-mapping.storage-strategy=PERSISTENT")
+@ActiveProfiles("test")
+class CommandReplayResultMapping_IntegTest extends CausewayIntegrationTestAbstract {
+
+    @Inject CommandReplayResultMappingRepository repository;
+    @Inject CommandReplayMappingListener mappingListener;
+    @Inject InteractionService interactionService;
+
+    @BeforeEach
+    void setUp() {
+        interactionService.nextInteraction();
+        repository.removeAll();
+    }
+
+    @Test
+    void jpaRepositoryPersistsAndQueriesIdentityChangedAndInteractionId() {
+        var interactionId = UUID.randomUUID();
+        var identity = repository.createAndPersist(bookmark("1"), bookmark("1"), null);
+        var firstChanged = repository.createAndPersist(bookmark("2"), bookmark("9"), interactionId);
+        var secondChanged = repository.createAndPersist(bookmark("3"), bookmark("9"), null);
+
+        assertThat(new ArrayList<Object>(repository.findAll()))
+                .containsExactly(identity, firstChanged, secondChanged);
+        assertThat(new ArrayList<Object>(repository.findChanged()))
+                .containsExactly(firstChanged, secondChanged);
+        assertThat(repository.findByRecordedBookmark(bookmark("2"))).contains(firstChanged);
+        assertThat(new ArrayList<Object>(repository.findByActualBookmark(bookmark("9"))))
+                .containsExactly(firstChanged, secondChanged);
+        assertThat(repository.findByRecordedBookmark(bookmark("2")).orElseThrow().getCommandInteractionId())
+                .isEqualTo(interactionId);
+    }
+
+    @Test
+    void persistentListenerIsSelectedAndSurvivesRepositoryReads() {
+        assertThat(mappingListener).isInstanceOf(CommandReplayMappingListenerPersistent.class);
+        var interactionId = UUID.randomUUID();
+
+        mappingListener.onReplayResult(bookmark("4"), bookmark("5"), interactionId);
+
+        assertThat(repository.findByRecordedBookmark(bookmark("4"))).get()
+                .extracting(
+                        mapping -> mapping.getActualBookmark(),
+                        mapping -> mapping.getCommandInteractionId())
+                .containsExactly(bookmark("5"), interactionId);
+        assertThat(mappingListener.lookup(bookmark("4"))).contains(bookmark("5"));
+    }
+
+    @Test
+    void recordedBookmarkHasDatabaseUniquenessMetadata() {
+        var table = CommandReplayResultMapping.class.getAnnotation(Table.class);
+
+        assertThat(table.uniqueConstraints()).singleElement().satisfies(constraint ->
+                assertThat(constraint.columnNames()).containsExactly("recordedBookmark"));
+        assertThat(table.indexes()).extracting(index -> index.columnList())
+                .containsExactly("recordedBookmark", "actualBookmark");
+    }
+
+    @Test
+    void repositoryCanRemoveOneOrAllMappings() {
+        var first = repository.createAndPersist(bookmark("6"), bookmark("7"), null);
+        repository.createAndPersist(bookmark("8"), bookmark("9"), null);
+
+        repository.remove(first);
+        assertThat(repository.findAll()).hasSize(1);
+
+        repository.removeAll();
+        assertThat(repository.findAll()).isEmpty();
+    }
+
+    private static Bookmark bookmark(final String id) {
+        return Bookmark.forLogicalTypeNameAndIdentifier("demo.Invoice", id);
+    }
+}
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/tasks.md b/openspec/changes/reconcile-persistent-replay-mapping/tasks.md
index 9d9860412fe..8cc62c8a2db 100644
--- a/openspec/changes/reconcile-persistent-replay-mapping/tasks.md
+++ b/openspec/changes/reconcile-persistent-replay-mapping/tasks.md
@@ -1,34 +1,34 @@
 ## 1. Applib persistent mapping model
 
-- [ ] 1.1 Add the abstract `CommandReplayResultMapping` domain type with recorded bookmark, actual bookmark, optional command interaction id, stable logical type, title behavior, and public API documentation.
-- [ ] 1.2 Add the persistence-neutral repository interface and reusable repository base for recorded/actual lookup, all/changed listing, create-and-flush, single removal support, and bulk removal.
-- [ ] 1.3 Add focused applib tests for mapping initialization, title and interaction-id behavior, repository query selection and parameters, creation, and removal.
+- [x] 1.1 Add the abstract `CommandReplayResultMapping` domain type with recorded bookmark, actual bookmark, optional command interaction id, stable logical type, title behavior, and public API documentation.
+- [x] 1.2 Add the persistence-neutral repository interface and reusable repository base for recorded/actual lookup, all/changed listing, create-and-flush, single removal support, and bulk removal.
+- [x] 1.3 Add focused applib tests for mapping initialization, title and interaction-id behavior, repository query selection and parameters, creation, and removal.
 
 ## 2. Persistent listener and conditional wiring
 
-- [ ] 2.1 Implement `CommandReplayMappingListenerPersistent` against the applib repository using bookmark-only lookup and the existing replay-result-mapping conflict policy.
-- [ ] 2.2 Preserve first-observation semantics for identity and changed mappings, idempotent repeats, conflicting repeats, and originating interaction ids.
-- [ ] 2.3 Add Spring Boot 4 conditional wiring for `PERSISTENT` storage with repository presence and custom-listener back-off, while keeping the in-memory and persistent defaults mutually exclusive.
-- [ ] 2.4 Add listener and application-context tests for lookup, identity mapping, creation, interaction-id retention, both conflict policies, repository absence, custom-listener back-off, and `IN_MEMORY` exclusion.
+- [x] 2.1 Implement `CommandReplayMappingListenerPersistent` against the applib repository using bookmark-only lookup and the existing replay-result-mapping conflict policy.
+- [x] 2.2 Preserve first-observation semantics for identity and changed mappings, idempotent repeats, conflicting repeats, and originating interaction ids.
+- [x] 2.3 Add Spring Boot 4 conditional wiring for `PERSISTENT` storage with repository presence and custom-listener back-off, while keeping the in-memory and persistent defaults mutually exclusive.
+- [x] 2.4 Add listener and application-context tests for lookup, identity mapping, creation, interaction-id retention, both conflict policies, repository absence, custom-listener back-off, and `IN_MEMORY` exclusion.
 
 ## 3. Causeway 4 JPA persistence
 
-- [ ] 3.1 Add the Jakarta Persistence replay-result mapping entity with bookmark conversion, optional UUID interaction id, recorded-bookmark uniqueness, recorded/actual indexes, named queries, and Causeway entity integration.
-- [ ] 3.2 Add the concrete JPA repository and register the entity, repository, and persistent-listener configuration with the commandlog JPA module and entity scan.
-- [ ] 3.3 Extend commandlog JPA teardown to remove replay-result mappings without changing command-log entry behavior.
-- [ ] 3.4 Add JPA integration tests for create-and-flush, identity and changed rows, recorded and actual bookmark finders, changed/all listing, uniqueness, interaction-id persistence, and teardown.
+- [x] 3.1 Add the Jakarta Persistence replay-result mapping entity with bookmark conversion, optional UUID interaction id, recorded-bookmark uniqueness, recorded/actual indexes, named queries, and Causeway entity integration.
+- [x] 3.2 Add the concrete JPA repository and register the entity, repository, and persistent-listener configuration with the commandlog JPA module and entity scan.
+- [x] 3.3 Extend commandlog JPA teardown to remove replay-result mappings without changing command-log entry behavior.
+- [x] 3.4 Add JPA integration tests for create-and-flush, identity and changed rows, recorded and actual bookmark finders, changed/all listing, uniqueness, interaction-id persistence, and teardown.
 
 ## 4. Mapping inspection and deletion UI
 
-- [ ] 4.1 Add optional-repository commandlog menu actions for all mappings, changed mappings, recorded-bookmark lookup, and actual-bookmark lookup, hidden when persistence is unavailable.
-- [ ] 4.2 Add fallback layout and column-order metadata displaying recorded bookmark, actual bookmark, and optional originating interaction id.
-- [ ] 4.3 Add an idempotent are-you-sure entity mixin for deleting one mapping and include it in module registration and fallback layout.
-- [ ] 4.4 Add an idempotent are-you-sure commandlog menu action for deleting all mappings, reporting the deleted count and leaving command log entries untouched.
-- [ ] 4.5 Order export/replay actions before mapping finders and the bulk-delete action after them, with focused menu, layout, visibility, deletion, and ordering tests.
+- [x] 4.1 Add optional-repository commandlog menu actions for all mappings, changed mappings, recorded-bookmark lookup, and actual-bookmark lookup, hidden when persistence is unavailable.
+- [x] 4.2 Add fallback layout and column-order metadata displaying recorded bookmark, actual bookmark, and optional originating command interaction id.
+- [x] 4.3 Add an idempotent are-you-sure entity mixin for deleting one mapping and include it in module registration and fallback layout.
+- [x] 4.4 Add an idempotent are-you-sure commandlog menu action for deleting all mappings, reporting the deleted count and leaving command log entries untouched.
+- [x] 4.5 Order export/replay actions before mapping finders and the bulk-delete action after them, with focused menu, layout, visibility, deletion, and ordering tests.
 
 ## 5. Documentation and verification
 
-- [ ] 5.1 Document persistent storage activation, the commandlog JPA module/schema prerequisite, custom-listener back-off, first-mapping/conflict semantics, interaction-id retention, management actions, and the Causeway 4 JPA-only adaptation.
-- [ ] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing in-memory mapping and replay lifecycle regressions.
-- [ ] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
-- [ ] 5.4 Confirm the implementation introduces no commandlog JDO module and no P1/P2, R1/R2, E1/W1, or B1/B2 behavior.
+- [x] 5.1 Document persistent storage activation, the commandlog JPA module/schema prerequisite, custom-listener back-off, first-mapping/conflict semantics, interaction-id retention, management actions, and the Causeway 4 JPA-only adaptation.
+- [x] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing in-memory mapping and replay lifecycle 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 introduces no commandlog JDO module and no P1/P2, R1/R2, E1/W1, or B1/B2 behavior.


(causeway) 11/15: CAUSEWAY-3910: propose unified command manager reconciliation

Posted by da...@apache.org.
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 fd0fdc012d4e6e00b0e0bfe31b4ef8795c7fb117
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 07:29:01 2026 +0100

    CAUSEWAY-3910: propose unified command manager reconciliation
---
 .../.openspec.yaml                                 |   2 +
 .../reconcile-unified-command-manager/design.md    |  94 +++++++++++++++++
 .../reconcile-unified-command-manager/proposal.md  |  31 ++++++
 .../specs/replayable-command-projection/spec.md    |  63 ++++++++++++
 .../specs/unified-command-manager/spec.md          | 114 +++++++++++++++++++++
 .../reconcile-unified-command-manager/tasks.md     |  35 +++++++
 6 files changed, 339 insertions(+)

diff --git a/openspec/changes/reconcile-unified-command-manager/.openspec.yaml b/openspec/changes/reconcile-unified-command-manager/.openspec.yaml
new file mode 100644
index 00000000000..84cfc124590
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-06
diff --git a/openspec/changes/reconcile-unified-command-manager/design.md b/openspec/changes/reconcile-unified-command-manager/design.md
new file mode 100644
index 00000000000..008816c307c
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/design.md
@@ -0,0 +1,94 @@
+## Context
+
+P1 established the reusable `ReplayableCommand` eligibility policy, participant projection, result-presence metadata, and adjacent navigation. Causeway 4 still presents command review through separate `CommandExportManager` and `CommandReplayManager` view models, whose timestamp-only state and workflow-specific collections prevent later reachability, export/import, workflow, and background-gate slices from sharing one sequence context.
+
+The maintenance branch provides a unified, baseline-bounded `CommandManager` with four read collections and baseline/limit controls. Its observable behavior is authoritative, but Causeway 4 must also preserve the legacy `EXPORTED` replay state, existing manager logical types and bookmarks, Jakarta APIs, the persistence-neutral repository split, and P1 eligibility. P2 must not pull forward the later R1/R2, E1, W1, or B1/B2 concerns.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Add one command-recording-suppressed `CommandManager` as the primary commandlog review surface.
+- Give the manager stable baseline and page-limit state with safe, non-recording navigation controls.
+- Present ordered `commandsInSequence`, `excluded`, `pendingOrFailed`, and `recordedOrReplayed` collections with explicit replay-state and P1 eligibility rules.
+- Preserve Causeway 4's persisted `EXPORTED` state as visible historical work.
+- Preserve old manager bookmarks and provide a safe route from each legacy manager to the unified manager.
+- Add only the persistence-neutral repository operations and JPA named queries needed by the new read model.
+- Broaden replay/retry action eligibility to the P2 state set without implementing B2's background-completion gate.
+
+**Non-Goals:**
+
+- Do not add export roots, participant tracking, `knownParticipants`, reference-data classification, or reachability validation from R1/R2.
+- Do not redesign YAML export/import, remap exported command sequences, or validate selection from E1.
+- Do not implement exclusion/restoration/deletion/movement/retimestamping workflows from W1.
+- Do not add background-state reporting or replay/export completion gates from B1/B2.
+- Do not remove the legacy manager logical types, change their timestamp-only mementos, migrate stored bookmarks, change command persistence, or restore commandlog JDO.
+
+## Decisions
+
+### Add one stateful manager without a workflow mode
+
+Add `CommandManager` with logical type `causeway.ext.commandLog.CommandManager`. It implements `ViewModel` and `CommandRecordingSuppressed` and carries exactly two pieces of state: an inclusive baseline timestamp and a positive page limit. The default limit is 100. Its canonical memento is `<timestamp>--<limit>`; the framework constructor also accepts a timestamp-only value and supplies the default limit. Missing or malformed components fall back independently to the current timestamp or [...]
+
+No export/replay mode is encoded. The collections themselves provide the useful groupings, allowing later slices to add actions without creating divergent manager identities.
+
+Separate mode-specific managers or a mode field were rejected because they preserve the split P2 is intended to remove. Reusing a timestamp-only memento was rejected because changing the page size would then fail to survive bookmark navigation.
+
+### Reuse safe baseline and limit controls
+
+Introduce small `HasBaseline` and `HasLimit` contracts and manager mixins for previous hour, next hour, change baseline, and change limit. Every action is safe, has command and execution publishing disabled, and returns a new manager retaining the other state component. A non-positive or unparsable limit uses the default rather than issuing an unbounded read.
+
+The legacy managers keep their existing controls and mementos. Retrofitting their URLs with a limit was rejected because it would break their compatibility role.
+
+### Define four disjoint review intentions over foreground commands
+
+`commandsInSequence` reads foreground entries at or after the baseline in repository order, applies the page limit at the repository boundary, excludes only `EXCLUDED`, and applies P1 general eligibility. It therefore includes eligible Causeway 4 entries in `UNDEFINED`, `EXPORTED`, `PENDING`, `OK`, and `FAILED` states.
+
+`excluded` reads `EXCLUDED` foreground entries at or after the baseline and applies P1 general eligibility. `pendingOrFailed` reads `PENDING` and `FAILED` entries and deliberately wraps every repository result, preserving P1's imported-work exception. `recordedOrReplayed` reads `UNDEFINED`, legacy `EXPORTED`, and `OK` entries and applies P1 general eligibility. These focused review collections are baseline-bounded but not truncated by the sequence page limit; the limit controls only `com [...]
+
+Treating `EXPORTED` as excluded or invisible was rejected because Causeway 4 has persisted that state as normal historical work. Reclassifying it would silently hide commands during migration.
+
+### Extend repository semantics rather than filtering complete tables
+
+Add persistence-neutral repository methods for excluded and recorded-or-replayed foreground reads. Reuse the existing pending-or-failed query and bounded foreground query. Extend the reusable repository base and JPA named-query declarations to express the three-state `UNDEFINED`/`EXPORTED`/`OK` read with the established timestamp and interaction-id ordering. No entity field, index, or schema changes are introduced.
+
+Loading all commands and filtering in a view model was rejected because collection semantics and ordering belong at the repository boundary and may operate over large command logs.
+
+### Make the unified entry point primary while retaining compatibility shims
+
+Add one prototyping menu action that opens `CommandManager` with the current hour as baseline and the default limit. Keep the existing `exportManager` and `replayManager` menu action identifiers registered but hidden, so customized metadata and direct callers are not broken while the standard menu exposes one manager entry point.
+
+Keep `CommandExportManager` and `CommandReplayManager`, their logical type names, timestamp-only framework constructors, collections, and existing workflow actions. Add a safe, publishing-disabled `openCommandManager` action to each that transfers its baseline and supplies the default limit. The new manager's timestamp-only constructor provides the inverse compatibility path for links that supply only legacy state; existing bookmarks are not rewritten automatically.
+
+Deleting the old types was rejected because serialized view-model bookmarks include their logical types. Leaving all three menu launchers visible was rejected because it would not establish the unified surface as primary.
+
+### Separate replay eligibility from exclusion eligibility
+
+The replay/retry action is enabled for `PENDING`, `OK`, and `FAILED` commands. It is disabled for `UNDEFINED`, legacy `EXPORTED`, and `EXCLUDED`. This predicate is independent of the current exclusion predicate, which remains limited to its existing P2-external workflow behavior. No background check is added; B2 will conjunct its completion gate later.
+
+Reusing `isPendingOrFailed` was rejected because P2 explicitly allows replaying an `OK` command. Broadening the shared exclusion predicate at the same time was rejected because exclusion workflow belongs to W1.
+
+### Keep presentation useful but free of later-slice controls
+
+The fallback manager layout shows baseline and limit controls in a header area and places `pendingOrFailed` and `recordedOrReplayed` under a replay-oriented tab, with `commandsInSequence` and `excluded` as sequence-review collections. Collection tables use the P1 columns needed to identify and assess a command—interaction id, timestamp, member, replay state, and result presence—and do not expose `knownParticipants` or later export/workflow actions.
+
+The module registers the manager and its mixins explicitly. All manager reads and controls remain non-mutating and command-recording-suppressed.
+
+## Risks / Trade-offs
+
+- [Legacy menu actions remain registered but hidden] → Retain their action identifiers and implementations, and cover both hidden support and direct invocation with compatibility tests.
+- [A page limit can split commands sharing a timestamp] → Preserve the repository's timestamp/interaction-id ordering and treat the limit as a display page, not an export transaction boundary.
+- [The four collections intentionally overlap] → Name and document them as review perspectives, and test state membership independently rather than implying partitioning.
+- [Causeway 4's `EXPORTED` state does not exist in the same form in maintenance] → Include it in general and recorded-history views but never make it replayable.
+- [Tolerant memento parsing can conceal invalid input] → Fall back independently and test canonical, timestamp-only, blank, malformed timestamp, malformed limit, and non-positive limit cases.
+- [Broader replay of `OK` commands can repeat domain behavior] → Keep replay explicit, retain existing confirmation/transaction behavior, and change only the action's state guard.
+
+## Migration Plan
+
+Deploy commandlog applib and JPA changes together. The standard menu will show the new manager; old `CommandExportManager` and `CommandReplayManager` bookmarks continue to deserialize and their screens offer `openCommandManager`. Existing commands, including those in `EXPORTED`, require no data migration. Custom menus may continue invoking the registered legacy action identifiers during a transition, although those actions are hidden in the standard UI.
+
+Rollback removes the new manager, mixins, and repository query wiring and restores visibility of the two legacy launchers. Because P2 adds no persistent fields and rewrites no bookmarks or replay states, rollback requires no datastore repair.
+
+## Open Questions
+
+None. The earlier P1 question about separate manager mementos is resolved by retaining both legacy logical types and mementos as shims while making the unified manager and its baseline/limit memento the forward path.
diff --git a/openspec/changes/reconcile-unified-command-manager/proposal.md b/openspec/changes/reconcile-unified-command-manager/proposal.md
new file mode 100644
index 00000000000..0094155aa48
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/proposal.md
@@ -0,0 +1,31 @@
+## Why
+
+With the P1 replayable-command projection complete, command review is still split between `CommandExportManager` and `CommandReplayManager`, each with different collections, controls, and mementos. P2 establishes the maintenance branch's single baseline-bounded sequence surface so later reachability, export/import, workflow, and background-gate slices can share one manager context.
+
+## What Changes
+
+- Add a public, command-recording-suppressed `CommandManager` view model with baseline and page-limit state, a stable baseline/limit memento, and safe shared controls for changing that state.
+- Add unified `commandsInSequence`, `excluded`, `pendingOrFailed`, and `recordedOrReplayed` collections using P1 eligibility, foreground ordering, replay-state boundaries, and the pending-work exception.
+- Preserve Causeway 4's legacy `EXPORTED` state as visible non-excluded historical work during the transition rather than hiding persisted commands.
+- Add the persistence-neutral and Jakarta Persistence repository queries needed for excluded and recorded-or-replayed collections without changing the datastore schema.
+- Make the unified manager the primary commandlog menu entry point and add fallback layout and collection column metadata.
+- Retain the legacy export/replay manager logical types and memento constructors as compatibility shims, add safe navigation from each to the unified manager, and accept a legacy timestamp-only value when constructing the new manager (using the default page limit).
+- Update replay-or-retry eligibility so `PENDING`, `OK`, and `FAILED` commands are replayable while `UNDEFINED`, `EXPORTED`, and `EXCLUDED` commands remain disabled; background-work gating remains deferred to B2.
+- Keep known-participant tracking and reachability, sequence YAML export/import redesign, exclusion/restoration/deletion/movement/retimestamping workflows, and background-completion gates outside this change.
+
+## Capabilities
+
+### New Capabilities
+
+- `unified-command-manager`: Defines unified manager identity and state, compatibility migration, baseline/limit controls, ordered command collections, menu and fallback presentation, and repository-neutral collection semantics.
+
+### Modified Capabilities
+
+- `replayable-command-projection`: Extends the shared eligibility contract to unified-manager collections and defines P2 replay-or-retry state eligibility without adding the later background gate.
+
+## Impact
+
+- `extensions/core/commandlog/applib`: adds the unified manager, state-control and compatibility mixins, menu/module wiring, collection filtering, layout metadata, replay-action policy, and focused tests while retaining the two legacy manager types.
+- `extensions/core/commandlog/persistence-jpa`: adds named-query wiring for the new replay-state collection reads; no entity or schema migration is required.
+- Existing bookmarks for `CommandExportManager` and `CommandReplayManager` remain loadable. New navigation uses `causeway.ext.commandLog.CommandManager` mementos containing baseline and limit.
+- P1 eligibility and replay context are reused; no dependency on reference-data, reachability, YAML redesign, persistent participant state, command mutation workflows, background gating, or commandlog JDO is introduced.
diff --git a/openspec/changes/reconcile-unified-command-manager/specs/replayable-command-projection/spec.md b/openspec/changes/reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
new file mode 100644
index 00000000000..b22e11944cd
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
@@ -0,0 +1,63 @@
+## MODIFIED Requirements
+
+### Requirement: Replay-useful entries are eligible for command projections
+The system SHALL apply a reusable replayable-command eligibility policy before wrapping command log entries in general command-sequence, review, and adjacent-navigation projections.
+State-changing command entries SHALL be eligible subject to the collection's existing replay-state, baseline, and limit filters.
+Safe action entries SHALL be eligible for those projections only when they store a non-null result bookmark.
+When action semantics cannot be resolved, the entry SHALL remain eligible.
+Ineligible entries MUST remain persisted and MUST NOT be mutated.
+The unified manager's `commandsInSequence`, `excluded`, and `recordedOrReplayed` collections and the legacy export and completed-or-excluded manager collections SHALL apply this policy without changing the legacy manager logical types, mementos, baselines, or actions.
+
+#### Scenario: State-changing command remains eligible
+- **WHEN** a general command projection considers a state-changing command entry
+- **THEN** the entry remains eligible for `ReplayableCommand` wrapping
+
+#### Scenario: Safe action with result is eligible
+- **WHEN** a general command projection considers a safe action entry with a stored result bookmark
+- **THEN** the entry is eligible for `ReplayableCommand` wrapping
+
+#### Scenario: Safe action without result is omitted without mutation
+- **WHEN** a general command projection considers a safe action entry with no stored result bookmark
+- **THEN** the entry is not wrapped as a `ReplayableCommand`
+- **AND** the command log entry remains persisted and unchanged
+
+#### Scenario: Unknown action semantics are retained
+- **WHEN** the metamodel cannot resolve the semantics of a command entry's action
+- **THEN** the entry remains eligible for `ReplayableCommand` wrapping
+
+### Requirement: Pending replay work bypasses general eligibility
+The unified manager's and legacy replay manager's pending-or-failed collections SHALL wrap every command entry returned by their repository query.
+A pending-or-failed safe action entry with no stored result bookmark SHALL remain visible for replay or retry review.
+This exception MUST NOT broaden eligibility in other projections.
+
+#### Scenario: Resultless safe action remains visible as imported work
+- **WHEN** the pending-or-failed repository query returns a safe action entry with no result bookmark
+- **THEN** each pending-or-failed collection wraps it as a `ReplayableCommand`
+
+#### Scenario: Pending exception does not affect general collections
+- **WHEN** the same resultless safe action is considered by `commandsInSequence`
+- **THEN** the general collection does not wrap it
+
+## ADDED Requirements
+
+### Requirement: Replay or retry uses the P2 replay-state boundary
+The replay-or-retry action SHALL be enabled for a replayable command in state `PENDING`, `OK`, or `FAILED`.
+It SHALL be disabled for state `UNDEFINED`, legacy `EXPORTED`, or `EXCLUDED`.
+The action guard SHALL use a replay-specific predicate and MUST NOT broaden exclusion-action eligibility.
+P2 SHALL NOT add a background-completion condition to this guard.
+
+#### Scenario: Successful command can be replayed again
+- **WHEN** a replayable command is in state `OK`
+- **THEN** replay-or-retry is enabled
+
+#### Scenario: Historical exported command cannot be replayed
+- **WHEN** a replayable command is in state `EXPORTED`
+- **THEN** replay-or-retry is disabled
+
+#### Scenario: Excluded command cannot be replayed
+- **WHEN** a replayable command is in state `EXCLUDED`
+- **THEN** replay-or-retry is disabled
+
+#### Scenario: Background gating remains deferred
+- **WHEN** a replayable command is in `PENDING`, `OK`, or `FAILED` and background metadata is absent or incomplete
+- **THEN** P2 state eligibility alone does not disable replay-or-retry
diff --git a/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md b/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md
new file mode 100644
index 00000000000..f2bbf1054b4
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md
@@ -0,0 +1,114 @@
+## ADDED Requirements
+
+### Requirement: Unified command manager has stable baseline and limit state
+The system SHALL provide a command-recording-suppressed `CommandManager` view model with logical type `causeway.ext.commandLog.CommandManager`.
+The manager SHALL carry an inclusive baseline timestamp and a positive page limit, with a default limit of 100.
+Its canonical memento SHALL encode `<timestamp>--<limit>` and SHALL NOT encode an export or replay mode.
+The framework constructor SHALL also accept a timestamp-only memento and use the default limit.
+Missing or malformed timestamp and limit components SHALL fall back independently to the current timestamp and default limit, and a non-positive limit SHALL use the default limit.
+
+#### Scenario: Canonical memento round-trips state
+- **WHEN** a manager with baseline `2026-08-06 10:00:00` and limit `50` is converted to and reconstructed from its memento
+- **THEN** the reconstructed manager has the same baseline and limit
+- **AND** the memento contains no workflow mode
+
+#### Scenario: Timestamp-only state remains usable
+- **WHEN** the manager is constructed from a valid timestamp-only memento
+- **THEN** it uses that timestamp as its baseline
+- **AND** it uses page limit 100
+
+#### Scenario: Invalid components fall back independently
+- **WHEN** a manager memento contains a valid timestamp and a non-positive or malformed limit
+- **THEN** it retains the supplied timestamp and uses page limit 100
+- **AND** a malformed timestamp does not prevent a valid positive limit from being retained
+
+### Requirement: Manager state controls are safe and context preserving
+The manager SHALL provide previous-hour, next-hour, change-baseline, and change-limit controls.
+Each control SHALL have safe semantics with command publishing and execution publishing disabled.
+Each control SHALL return a new `CommandManager`, changing only its designated state component and retaining the other component.
+Invoking a state control MUST NOT mutate a command log entry.
+
+#### Scenario: Moving the baseline retains the limit
+- **WHEN** previous-hour is invoked on a manager with baseline `2026-08-06 10:00:00` and limit `50`
+- **THEN** the returned manager has baseline `2026-08-06 09:00:00` and limit `50`
+- **AND** no command is recorded or modified
+
+#### Scenario: Changing the limit retains the baseline
+- **WHEN** change-limit selects `200` on a manager with baseline `2026-08-06 10:00:00`
+- **THEN** the returned manager has the same baseline and limit `200`
+- **AND** no command is recorded or modified
+
+### Requirement: Commands in sequence are bounded ordered foreground work
+`commandsInSequence` SHALL query foreground commands at or after the manager baseline using the manager page limit and established commandlog ordering.
+It SHALL exclude entries in replay state `EXCLUDED` and apply the general replayable-command eligibility policy before wrapping entries.
+Eligible entries in `UNDEFINED`, `EXPORTED`, `PENDING`, `OK`, and `FAILED` states SHALL remain visible.
+Reading the collection MUST NOT mutate, reorder, or change the replay state of any command.
+
+#### Scenario: Sequence includes every eligible non-excluded state
+- **WHEN** eligible foreground entries in `UNDEFINED`, `EXPORTED`, `PENDING`, `OK`, `FAILED`, and `EXCLUDED` states occur at or after the baseline
+- **THEN** `commandsInSequence` contains the first five in repository order
+- **AND** it does not contain the `EXCLUDED` entry
+
+#### Scenario: Sequence applies page limit at the repository boundary
+- **WHEN** more eligible non-excluded foreground entries exist than the manager page limit
+- **THEN** `commandsInSequence` returns at most the configured number in established commandlog order
+
+#### Scenario: Ineligible general work is omitted without mutation
+- **WHEN** the bounded query returns a safe action with no result bookmark
+- **THEN** `commandsInSequence` does not wrap that entry
+- **AND** the persisted entry remains unchanged
+
+### Requirement: Unified manager exposes focused replay-state collections
+The manager SHALL expose baseline-bounded `excluded`, `pendingOrFailed`, and `recordedOrReplayed` collections in established commandlog order.
+`excluded` SHALL contain entries in `EXCLUDED` state and apply the general replayable-command eligibility policy.
+`pendingOrFailed` SHALL contain entries in `PENDING` or `FAILED` state and wrap every repository result without applying general eligibility.
+`recordedOrReplayed` SHALL contain entries in `UNDEFINED`, legacy `EXPORTED`, or `OK` state and apply general eligibility.
+These collections SHALL NOT be truncated by the `commandsInSequence` page limit and MUST NOT mutate command entries.
+
+#### Scenario: Excluded review contains eligible excluded work
+- **WHEN** eligible and ineligible foreground commands in `EXCLUDED` state occur at or after the baseline
+- **THEN** `excluded` contains only the eligible commands in repository order
+
+#### Scenario: Pending work retains the eligibility exception
+- **WHEN** the pending-or-failed query returns a resultless safe action in `PENDING` state
+- **THEN** `pendingOrFailed` wraps it as a `ReplayableCommand`
+
+#### Scenario: Recorded history includes legacy exported work
+- **WHEN** eligible commands in `UNDEFINED`, `EXPORTED`, and `OK` states occur at or after the baseline
+- **THEN** `recordedOrReplayed` contains all three in repository order
+
+#### Scenario: Focused collections ignore the sequence limit
+- **WHEN** a focused collection contains more entries than the manager page limit
+- **THEN** all matching entries at or after the baseline remain available in that focused collection
+
+### Requirement: Unified manager is the primary compatible entry point
+The standard commandlog menu SHALL expose one prototyping action that opens `CommandManager` with the current hour as baseline and page limit 100.
+The legacy export-manager and replay-manager menu action identifiers SHALL remain registered but SHALL be hidden from the standard menu.
+`CommandExportManager` and `CommandReplayManager` SHALL retain their logical types, timestamp-only memento constructors, existing behavior, and direct construction compatibility.
+Each legacy manager SHALL expose a safe, command- and execution-publishing-disabled `openCommandManager` action that transfers its baseline and uses page limit 100.
+Existing legacy bookmarks MUST NOT be rewritten or invalidated.
+
+#### Scenario: Standard menu opens one unified manager
+- **WHEN** the commandlog prototyping menu is rendered
+- **THEN** it offers the unified command-manager action
+- **AND** the legacy export-manager and replay-manager launchers are hidden
+
+#### Scenario: Legacy export bookmark migrates safely
+- **WHEN** an existing `CommandExportManager` bookmark is opened and `openCommandManager` is invoked
+- **THEN** a `CommandManager` is returned with the legacy manager baseline and page limit 100
+- **AND** no command entry is modified
+
+#### Scenario: Legacy replay bookmark remains loadable
+- **WHEN** an existing timestamp-only `CommandReplayManager` bookmark is resolved
+- **THEN** the legacy manager retains its previous behavior
+- **AND** it offers safe navigation to the unified manager
+
+### Requirement: Fallback presentation exposes P2 review metadata only
+The manager fallback layout SHALL expose baseline and limit with their state controls and SHALL present all four collections as sequence and replay review surfaces.
+Manager collection tables SHALL identify commands using interaction id, timestamp, member, replay state, and result presence.
+The layout MUST NOT expose known-participant status, reachability, redesigned export/import, workflow mutation, or background-gate controls.
+
+#### Scenario: Unified manager layout remains within P2 scope
+- **WHEN** the manager is rendered from fallback layout metadata
+- **THEN** baseline, limit, all four collections, and the P1 identification columns are visible
+- **AND** controls and columns owned by later reconciliation slices are absent
diff --git a/openspec/changes/reconcile-unified-command-manager/tasks.md b/openspec/changes/reconcile-unified-command-manager/tasks.md
new file mode 100644
index 00000000000..2f68c500bb4
--- /dev/null
+++ b/openspec/changes/reconcile-unified-command-manager/tasks.md
@@ -0,0 +1,35 @@
+## 1. Manager State and Controls
+
+- [ ] 1.1 Add `HasBaseline` and `HasLimit` contracts, tolerant baseline/limit memento parsing, and focused tests for canonical, timestamp-only, malformed, and non-positive state.
+- [ ] 1.2 Add the command-recording-suppressed `CommandManager` view model with logical type, default limit, title, canonical memento, and framework constructor.
+- [ ] 1.3 Add safe publishing-disabled previous-hour, next-hour, change-baseline, and change-limit mixins that preserve the manager's other state component, with unit tests.
+
+## 2. Persistence-Neutral Collection Queries
+
+- [ ] 2.1 Extend the command-entry repository contract and reusable implementation with excluded and recorded-or-replayed foreground queries while reusing bounded foreground and pending-or-failed reads.
+- [ ] 2.2 Add Jakarta Persistence named-query wiring for `EXCLUDED` and `UNDEFINED`/`EXPORTED`/`OK` reads with established ordering and no schema change.
+- [ ] 2.3 Add repository tests for inclusive baselines, replay-state membership, ordering, and bounded sequence reads.
+
+## 3. Unified Review Collections
+
+- [ ] 3.1 Implement `commandsInSequence` with repository-side limiting, `EXCLUDED` removal, P1 general eligibility, and replay-context-preserving wrapping.
+- [ ] 3.2 Implement `excluded` and `recordedOrReplayed` with their replay-state sets and P1 general eligibility.
+- [ ] 3.3 Implement `pendingOrFailed` using the direct repository result-to-wrapper path so resultless safe imported work bypasses general eligibility.
+- [ ] 3.4 Add manager collection tests covering all six Causeway 4 replay states, eligibility, ordering, sequence limiting, focused-collection non-limiting, and absence of mutation.
+
+## 4. Entry Point, Compatibility, and Presentation
+
+- [ ] 4.1 Add the unified prototyping menu action with current-hour baseline and default limit, retain but hide the legacy launch actions, and test menu defaults and visibility.
+- [ ] 4.2 Add safe publishing-disabled `openCommandManager` navigation to both legacy managers while preserving their logical types, timestamp-only constructors, collections, and existing actions; add bookmark/navigation compatibility tests.
+- [ ] 4.3 Register the manager and mixins in the commandlog applib module and add fallback object/collection layouts with only P1/P2 fields and controls.
+
+## 5. Replay State Policy
+
+- [ ] 5.1 Add a replay-specific state predicate that accepts `PENDING`, `OK`, and `FAILED` and rejects `UNDEFINED`, `EXPORTED`, and `EXCLUDED` without changing exclusion eligibility.
+- [ ] 5.2 Wire the replay-or-retry guard to the new predicate and add parameterized tests for every replay state and the deliberately absent background gate.
+
+## 6. Verification
+
+- [ ] 6.1 Run focused commandlog applib and JPA tests, including metamodel/layout tests for the new view model and compatibility actions.
+- [ ] 6.2 Run the affected commandlog Maven verification suite and confirm the change introduces no datastore migration or JDO dependency.
+- [ ] 6.3 Update affected commandlog reference documentation to describe the unified primary manager, page limit, four collections, legacy compatibility path, and P2 replay-state boundary.


(causeway) 13/15: CAUSEWAY-3910: archive unified command manager reconciliation

Posted by da...@apache.org.
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 722c1497b09e08e299eae3f9d76537ee9a5a8557
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 08:11:03 2026 +0100

    CAUSEWAY-3910: archive unified command manager reconciliation
---
 .../.openspec.yaml                                 |  0
 .../design.md                                      |  0
 .../proposal.md                                    |  0
 .../specs/replayable-command-projection/spec.md    |  0
 .../specs/unified-command-manager/spec.md          |  0
 .../tasks.md                                       |  0
 .../specs/replayable-command-projection/spec.md    | 32 ++++++++++++++++++----
 .../specs/unified-command-manager/spec.md          |  8 +++++-
 8 files changed, 34 insertions(+), 6 deletions(-)

diff --git a/openspec/changes/reconcile-unified-command-manager/.openspec.yaml b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/.openspec.yaml
similarity index 100%
rename from openspec/changes/reconcile-unified-command-manager/.openspec.yaml
rename to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/.openspec.yaml
diff --git a/openspec/changes/reconcile-unified-command-manager/design.md b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/design.md
similarity index 100%
rename from openspec/changes/reconcile-unified-command-manager/design.md
rename to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/design.md
diff --git a/openspec/changes/reconcile-unified-command-manager/proposal.md b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/proposal.md
similarity index 100%
rename from openspec/changes/reconcile-unified-command-manager/proposal.md
rename to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/proposal.md
diff --git a/openspec/changes/reconcile-unified-command-manager/specs/replayable-command-projection/spec.md b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
similarity index 100%
rename from openspec/changes/reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
rename to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/replayable-command-projection/spec.md
diff --git a/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/unified-command-manager/spec.md
similarity index 100%
copy from openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md
copy to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/specs/unified-command-manager/spec.md
diff --git a/openspec/changes/reconcile-unified-command-manager/tasks.md b/openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/tasks.md
similarity index 100%
rename from openspec/changes/reconcile-unified-command-manager/tasks.md
rename to openspec/changes/archive/2026-08-06-reconcile-unified-command-manager/tasks.md
diff --git a/openspec/specs/replayable-command-projection/spec.md b/openspec/specs/replayable-command-projection/spec.md
index 40ec290dd5c..9ce402564db 100644
--- a/openspec/specs/replayable-command-projection/spec.md
+++ b/openspec/specs/replayable-command-projection/spec.md
@@ -8,11 +8,11 @@ Define replay-useful command eligibility, result presence, bookmark participant
 
 ### Requirement: Replay-useful entries are eligible for command projections
 The system SHALL apply a reusable replayable-command eligibility policy before wrapping command log entries in general command-sequence, review, and adjacent-navigation projections.
-State-changing command entries SHALL be eligible subject to the collection's existing replay-state and baseline filters.
+State-changing command entries SHALL be eligible subject to the collection's existing replay-state, baseline, and limit filters.
 Safe action entries SHALL be eligible for those projections only when they store a non-null result bookmark.
 When action semantics cannot be resolved, the entry SHALL remain eligible.
 Ineligible entries MUST remain persisted and MUST NOT be mutated.
-The existing export and completed-or-excluded manager collections SHALL apply this policy without changing manager logical types, mementos, baselines, or actions.
+The unified manager's `commandsInSequence`, `excluded`, and `recordedOrReplayed` collections and the legacy export and completed-or-excluded manager collections SHALL apply this policy without changing the legacy manager logical types, mementos, baselines, or actions.
 
 #### Scenario: State-changing command remains eligible
 - **WHEN** a general command projection considers a state-changing command entry
@@ -32,18 +32,40 @@ The existing export and completed-or-excluded manager collections SHALL apply th
 - **THEN** the entry remains eligible for `ReplayableCommand` wrapping
 
 ### Requirement: Pending replay work bypasses general eligibility
-The existing pending-or-failed replay collection SHALL wrap every command entry returned by its repository query.
+The unified manager's and legacy replay manager's pending-or-failed collections SHALL wrap every command entry returned by their repository query.
 A pending-or-failed safe action entry with no stored result bookmark SHALL remain visible for replay or retry review.
 This exception MUST NOT broaden eligibility in other projections.
 
 #### Scenario: Resultless safe action remains visible as imported work
 - **WHEN** the pending-or-failed repository query returns a safe action entry with no result bookmark
-- **THEN** the pending-or-failed collection wraps it as a `ReplayableCommand`
+- **THEN** each pending-or-failed collection wraps it as a `ReplayableCommand`
 
 #### Scenario: Pending exception does not affect general collections
-- **WHEN** the same resultless safe action is considered by a general command-sequence collection
+- **WHEN** the same resultless safe action is considered by `commandsInSequence`
 - **THEN** the general collection does not wrap it
 
+### Requirement: Replay or retry uses the P2 replay-state boundary
+The replay-or-retry action SHALL be enabled for a replayable command in state `PENDING`, `OK`, or `FAILED`.
+It SHALL be disabled for state `UNDEFINED`, legacy `EXPORTED`, or `EXCLUDED`.
+The action guard SHALL use a replay-specific predicate and MUST NOT broaden exclusion-action eligibility.
+P2 SHALL NOT add a background-completion condition to this guard.
+
+#### Scenario: Successful command can be replayed again
+- **WHEN** a replayable command is in state `OK`
+- **THEN** replay-or-retry is enabled
+
+#### Scenario: Historical exported command cannot be replayed
+- **WHEN** a replayable command is in state `EXPORTED`
+- **THEN** replay-or-retry is disabled
+
+#### Scenario: Excluded command cannot be replayed
+- **WHEN** a replayable command is in state `EXCLUDED`
+- **THEN** replay-or-retry is disabled
+
+#### Scenario: Background gating remains deferred
+- **WHEN** a replayable command is in `PENDING`, `OK`, or `FAILED` and background metadata is absent or incomplete
+- **THEN** P2 state eligibility alone does not disable replay-or-retry
+
 ### Requirement: Replayable command reports result presence
 A replayable command SHALL expose a non-persisted Boolean property named `hasResult`.
 The property SHALL be `true` exactly when the wrapped command log entry stores a non-null result bookmark and SHALL otherwise be `false`.
diff --git a/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md b/openspec/specs/unified-command-manager/spec.md
similarity index 97%
rename from openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md
rename to openspec/specs/unified-command-manager/spec.md
index f2bbf1054b4..7340180d973 100644
--- a/openspec/changes/reconcile-unified-command-manager/specs/unified-command-manager/spec.md
+++ b/openspec/specs/unified-command-manager/spec.md
@@ -1,4 +1,10 @@
-## ADDED Requirements
+# Unified Command Manager Specification
+
+## Purpose
+
+Define the unified commandlog manager's identity, state, controls, foreground review collections, compatibility path, and P2 presentation boundary.
+
+## Requirements
 
 ### Requirement: Unified command manager has stable baseline and limit state
 The system SHALL provide a command-recording-suppressed `CommandManager` view model with logical type `causeway.ext.commandLog.CommandManager`.


(causeway) 06/15: CAUSEWAY-3910: update reconciliation ledger for M3

Posted by da...@apache.org.
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 611eda3992f028fb676ab71a2e698e4fae284d12
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 22:20:06 2026 +0100

    CAUSEWAY-3910: update reconciliation ledger for M3
---
 openspec/reconciliation/maintenance-branch/ledger.md | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/openspec/reconciliation/maintenance-branch/ledger.md b/openspec/reconciliation/maintenance-branch/ledger.md
index 9291a2e8599..b9c3e5c3e6f 100644
--- a/openspec/reconciliation/maintenance-branch/ledger.md
+++ b/openspec/reconciliation/maintenance-branch/ledger.md
@@ -36,7 +36,7 @@
 | CAUSEWAY-4010, 4024 | D1 | Result-bearing transfer DTOs, bookmark metadata, deep copying, and multi-document YAML foundations reconciled | Adapt | Completed by `reconcile-command-result-metadata` |
 | CAUSEWAY-4042 | D2 | Configurable command-execution advisor policies reconciled | Adapt | Completed by `reconcile-command-replay-mapping` |
 | CAUSEWAY-4010, 4039 | M1, M2 | Bookmark-only remapping SPI, execution-copy remapping, atomic result observation, and conditional in-memory listener reconciled | Supersede | Completed by `reconcile-command-replay-mapping` |
-| Later maintenance mapping work | M3 | Mapping entity, repository, and persistent listener absent | Supersede | `reconcile-persistent-replay-mapping` |
+| Later maintenance mapping work | M3 | Persistent mapping contracts, listener, management UI, and Jakarta Persistence adapter reconciled; the removed commandlog JDO adapter is not applicable on Causeway 4 | Supersede | Completed by `reconcile-persistent-replay-mapping` |
 | Maintenance consolidated replayable-command specs | P1 | Basic `ReplayableCommand` exists but lacks participants, result presence, actual mappings, and adjacent navigation | Supersede | `reconcile-replayable-command-projection` |
 | Maintenance consolidated manager specs | P2 | Separate export and replay managers exist | Supersede | `reconcile-unified-command-manager` |
 | CAUSEWAY-4034 and later maintenance specs | R1 | `RefData` and command replay reference-data SPI absent | Adapt | `reconcile-command-reference-data` |
@@ -54,6 +54,7 @@
 | `reconcile-recording-aware-publishing` | C2 | `openspec/specs/recording-aware-command-publishing/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-recording-aware-publishing/` | Planning `fc74666a15e`; implementation `35f1b39b3a7`; archive `37f6db329ab`; focused and aggregate Maven tests passed |
 | `reconcile-synthetic-command-navigation` | C3, C4b | `openspec/specs/synthetic-command-navigation/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-synthetic-command-navigation/` | Planning `9194e8317f8`; implementation `42b9ecbb433`; archive `ea0a1b8b4c1`; focused and affected aggregate Maven tests passed |
 | `reconcile-command-replay-mapping` | D2, M1, M2 | `openspec/specs/command-execution-advisor-policy/spec.md`; `openspec/specs/command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-command-replay-mapping/` | Planning `b9412ee98a7`; implementation `6b4ab64d193`; archive `74ba8b40c0a`; focused and affected aggregate Maven tests passed |
+| `reconcile-persistent-replay-mapping` | M3 | `openspec/specs/persistent-command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/` | Planning `d88eb297388`; implementation `1990363924f`; archive `d95ee405f68`; focused and affected aggregate Maven tests passed |
 
 ## Resolved questions
 
@@ -65,13 +66,13 @@
 | Recording support broadens publishing through the normal facet lifecycle; explicit safe-action disablement remains an opt-out, while property edits remain recording-eligible even when explicitly disabled so replay sequences stay complete. | C2 | `reconcile-recording-aware-publishing/design.md` and `RecordingAwareCommandPublishingFacetTest` |
 | Causeway 4 installs synthetic navigation actions through an `A0_BEFORE_BUILTIN` metamodel post-processor so later standard action processing observes them. | C3, C4b | `reconcile-synthetic-command-navigation/design.md` in the archived change and `SyntheticNavigationActionTest` |
 | Replay mapping policy remains entirely in commandlog applib through a bookmark-only listener SPI and result-remapping service; core runtime services implement only general command-execution advisor policy. | D2, M1, M2 | `reconcile-command-replay-mapping/design.md` in the archived change, `ResultRemappingServiceTest`, and `CommandExecutorInteractionAdvisorTest` |
+| Persistent replay-result mappings use the Causeway 4 commandlog JPA module only; the commandlog JDO adapter was deliberately removed and is not restored. | M3 | `reconcile-persistent-replay-mapping/design.md`, `CommandReplayResultMapping_IntegTest`, and the Causeway 4 commandlog module inventory |
 
 ## Open questions
 
 | Question | Affected nodes | Resolution point |
 |---|---|---|
 | Can the Causeway 4 Spring context become unavailable during `ServiceRegistryDefault.select()` in metamodel disposal? | CAUSEWAY-4002 | Separate lifecycle investigation before core work is declared complete |
-| How should persistent mapping selection work when both JPA and JDO modules are visible? | M3 | Persistent-mapping design |
 | How will migration from separate `CommandExportManager` and `CommandReplayManager` view-model mementos be handled? | P2 | Unified-manager design |
 | Which services count as export roots in the Causeway 4 service registry and logical-type model? | R2 | Reachability design |
 | Which persistence operations are required to retimestamp commands safely under JPA and JDO? | W1 | Manager-workflow design |
@@ -81,5 +82,5 @@
 Each child change must identify the maintenance scenarios it satisfies.
 Tests should be ported by observable behaviour rather than copied mechanically.
 Framework-level contracts should be tested in `api/applib`, `core/metamodel`, `core/runtimeservices`, or `core/mmtest` as appropriate.
-Commandlog behaviour should be tested in commandlog applib integration tests and in both persistence adapters when storage semantics are involved.
+Commandlog behaviour should be tested in commandlog applib integration tests and in every applicable persistence adapter when storage semantics are involved; omitted maintenance adapters must be justified as explicit Causeway 4 adaptations.
 Any deviation from maintenance behaviour must be recorded as an explicit Causeway 4 adaptation in the child change design.


(causeway) 05/15: CAUSEWAY-3910: archive persistent replay mapping change

Posted by da...@apache.org.
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 bf4db28cd0fac1f1bd4a39b23713558cfd1d8878
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 22:16:47 2026 +0100

    CAUSEWAY-3910: archive persistent replay mapping change
---
 .../.openspec.yaml                                                | 0
 .../2026-08-05-reconcile-persistent-replay-mapping}/design.md     | 0
 .../2026-08-05-reconcile-persistent-replay-mapping}/proposal.md   | 0
 .../specs/persistent-command-replay-mapping/spec.md               | 0
 .../2026-08-05-reconcile-persistent-replay-mapping}/tasks.md      | 0
 .../specs/persistent-command-replay-mapping/spec.md               | 8 +++++++-
 6 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/openspec/changes/reconcile-persistent-replay-mapping/.openspec.yaml b/openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/.openspec.yaml
similarity index 100%
rename from openspec/changes/reconcile-persistent-replay-mapping/.openspec.yaml
rename to openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/.openspec.yaml
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/design.md b/openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/design.md
similarity index 100%
rename from openspec/changes/reconcile-persistent-replay-mapping/design.md
rename to openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/design.md
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/proposal.md b/openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/proposal.md
similarity index 100%
rename from openspec/changes/reconcile-persistent-replay-mapping/proposal.md
rename to openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/proposal.md
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md b/openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
similarity index 100%
copy from openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
copy to openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/tasks.md b/openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/tasks.md
similarity index 100%
rename from openspec/changes/reconcile-persistent-replay-mapping/tasks.md
rename to openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/tasks.md
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md b/openspec/specs/persistent-command-replay-mapping/spec.md
similarity index 98%
rename from openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
rename to openspec/specs/persistent-command-replay-mapping/spec.md
index 94c990d1c55..cfdfe2c3494 100644
--- a/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
+++ b/openspec/specs/persistent-command-replay-mapping/spec.md
@@ -1,4 +1,10 @@
-## ADDED Requirements
+# Persistent Command Replay Mapping Specification
+
+## Purpose
+
+Define durable replay-result bookmark mappings, their JPA persistence, conditional listener activation, and commandlog management behavior.
+
+## Requirements
 
 ### Requirement: Persistent listener records replay-result mappings
 The system SHALL provide a persistent `CommandReplayMappingListener` that stores replay results by recorded result bookmark.


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

Posted by da...@apache.org.
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.


(causeway) 03/15: CAUSEWAY-3910: plan persistent replay mapping reconciliation

Posted by da...@apache.org.
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 e3fa23a20da1460eeb66977a4620a20e2a03c74b
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 21:45:00 2026 +0100

    CAUSEWAY-3910: plan persistent replay mapping reconciliation
---
 .../.openspec.yaml                                 |   2 +
 .../reconcile-persistent-replay-mapping/design.md  |  89 ++++++++++
 .../proposal.md                                    |  32 ++++
 .../persistent-command-replay-mapping/spec.md      | 187 +++++++++++++++++++++
 .../reconcile-persistent-replay-mapping/tasks.md   |  34 ++++
 5 files changed, 344 insertions(+)

diff --git a/openspec/changes/reconcile-persistent-replay-mapping/.openspec.yaml b/openspec/changes/reconcile-persistent-replay-mapping/.openspec.yaml
new file mode 100644
index 00000000000..1c37182ed64
--- /dev/null
+++ b/openspec/changes/reconcile-persistent-replay-mapping/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-05
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/design.md b/openspec/changes/reconcile-persistent-replay-mapping/design.md
new file mode 100644
index 00000000000..a4833a4bd98
--- /dev/null
+++ b/openspec/changes/reconcile-persistent-replay-mapping/design.md
@@ -0,0 +1,89 @@
+## Context
+
+The completed D2/M1/M2 reconciliation added bookmark-only replay input lookup, replay-result observation inside the replay transaction, and a built-in in-memory listener. It also introduced `causeway.extensions.command-log.replay-result-mapping.storage-strategy=PERSISTENT`, but deliberately provides no listener for that selection.
+
+Maintenance now has a persistent mapping model, repository abstraction, JPA and JDO adapters, query/menu actions, interaction-id capture, and deletion. Causeway 4 differs materially: CAUSEWAY-3866 removed the commandlog JDO persistence module, and the current commandlog reactor contains only applib and JPA persistence modules. This change therefore ports the observable persistent-mapping behavior onto Jakarta Persistence and Spring Boot 4 without restoring the removed JDO adapter.
+
+The persistent listener is called by the existing `ResultRemappingService` in the replay transaction. Its stored mappings must remain portable bookmarks and must preserve the first observed mapping and interaction id across retries and restarts.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Complete M3 with a durable built-in replay-mapping listener selected by the existing `PERSISTENT` strategy.
+- Persist identity and changed recorded-to-actual bookmark mappings and their optional originating interaction id.
+- Preserve the M2 first-observation, idempotency, conflict-policy, lookup, custom-listener back-off, and transactional semantics.
+- Provide common applib domain/repository/UI contracts and a concrete Causeway 4 JPA implementation.
+- Make persisted mappings inspectable and removable through commandlog UI actions.
+- Resolve the programme's JPA/JDO selection question explicitly for Causeway 4.
+
+**Non-Goals:**
+
+- Do not change `CommandReplayMappingListener`, `ResultRemappingService`, or the replay transaction boundary.
+- Do not restore the removed commandlog JDO persistence module or add a new persistence selector beyond the existing mapping storage strategy.
+- Do not add replayable-command participant projections, unified manager behavior, export validation/import, reference data, reachability, manager workflows, or background gates.
+- Do not migrate transient mappings already held by an in-memory listener into the datastore.
+- Do not add paging or an external/distributed cache.
+
+## Decisions
+
+### Implement the Causeway 4 adapter for JPA only
+
+Add the concrete mapping entity and repository to `extensions/core/commandlog/persistence-jpa` using Jakarta Persistence, the existing Causeway bookmark converter, entity listener, module import, and entity-scan conventions. The former commandlog JDO module was deliberately removed from Causeway 4 and is not a viable runtime choice, so recreating it solely for M3 would reverse an architectural decision and expand scope substantially.
+
+The alternative of porting both maintenance adapters was rejected because no Causeway 4 commandlog JDO entry repository or reactor module remains. Consequently, there is no dual-adapter ambiguity to resolve: the active commandlog persistence module supplies the single repository, which on Causeway 4 is JPA.
+
+### Keep persistence-neutral contracts and behavior in commandlog applib
+
+Define an abstract `CommandReplayResultMapping` domain type, a repository interface, and a reusable repository base in commandlog applib. The abstract mapping exposes recorded and actual `Bookmark` values plus an optional command interaction id. The repository supports lookup by recorded bookmark, lookup by actual bookmark, listing all and changed mappings, creation, and removal.
+
+The persistent listener also lives in applib and depends only on this repository contract. This mirrors the existing commandlog persistence split, keeps bookmark logic independent of JPA, and leaves applications free to provide their own repository implementation without changing the replay SPI.
+
+Putting the listener directly in the JPA module was considered, but rejected because conflict and lookup behavior is commandlog policy rather than datastore-specific behavior.
+
+### Preserve the first mapping with datastore enforcement
+
+Persist every first observation, including identity mappings. Make recorded bookmark unique and indexed, and index actual bookmark for reverse lookup. The listener first queries by recorded bookmark:
+
+- no row: create and flush a row containing recorded bookmark, actual bookmark, and the supplied interaction id;
+- same actual bookmark: treat the observation as idempotent and retain the original row and interaction id;
+- different actual bookmark: retain the original row and either throw or log according to the existing conflict policy.
+
+The database uniqueness constraint is the final guard against concurrent first observations. A concurrent uniqueness failure propagates from the replay transaction rather than overwriting an established mapping. An upsert was rejected because it could make the winning mapping datastore-dependent and violate first-observation semantics.
+
+### Reuse the existing conditional built-in-listener contract
+
+Register the persistent listener only when storage strategy is `PERSISTENT`, a `CommandReplayResultMappingRepository` bean is available, and no application-defined `CommandReplayMappingListener` bean exists. The current in-memory listener remains selected only for `IN_MEMORY`; the two built-ins are therefore mutually exclusive. A custom listener remains authoritative in either mode.
+
+The JPA commandlog module registers its concrete repository and imports the persistent-listener bean factory. If an application selects `PERSISTENT` without the JPA module or another repository implementation, no built-in listener is created, matching the existing conditional contract and avoiding a broken bean graph.
+
+### Expose persistent data through optional commandlog UI integration
+
+Inject the repository optionally into `CommandLogMenu`. Add safe prototyping actions to list all mappings, list changed mappings, find by recorded bookmark, and find by actual bookmark. Add an idempotent are-you-sure action to delete all mappings, reporting the number deleted without touching command log entries. Hide all mapping menu actions when no repository exists.
+
+Provide fallback layout metadata showing recorded bookmark, actual bookmark, and command interaction id. Contribute an idempotent are-you-sure delete mixin for removing one mapping. Place export/replay actions before mapping finders and bulk deletion after the finders to keep the maintenance workflow ordering.
+
+Keeping these actions in JPA was rejected because the UI contract is persistence-neutral and should disappear naturally when no repository implementation is present.
+
+### Treat the mapping table as an additive schema change
+
+Use the commandlog schema and a dedicated replay-result-mapping table with a generated surrogate key, non-null recorded and actual bookmark columns, and a nullable UUID interaction-id column. Registration and teardown are extended in the JPA module. Existing commandlog rows and replay DTOs are unchanged.
+
+## Risks / Trade-offs
+
+- [Risk] Selecting `PERSISTENT` without a repository silently yields no built-in listener. → Document the JPA module requirement and cover present/absent repository contexts explicitly.
+- [Risk] Two concurrent replays can both observe no mapping before insertion. → Enforce recorded-bookmark uniqueness, flush creation inside the replay transaction, and propagate the losing transaction's persistence failure rather than overwrite data.
+- [Risk] Bookmark column length or conversion could diverge from existing commandlog storage. → Reuse Causeway's `Bookmark` converter and established commandlog length constants/conventions.
+- [Risk] Listing all mappings can be large. → Match maintenance behavior for this reconciliation slice; retain focused finders and defer paging until usage demonstrates a need.
+- [Risk] Menu deletion is operationally destructive. → Use are-you-sure semantics, restrict the menu actions consistently with existing prototyping commandlog tools, and ensure deletion targets only mapping rows.
+- [Trade-off] JPA-only behavior differs from maintenance's JPA/JDO matrix. → Record this as an explicit Causeway 4 adaptation justified by removal of the commandlog JDO module.
+
+## Migration Plan
+
+Applications using the default `IN_MEMORY` strategy require no migration. Applications opting into `PERSISTENT` add or upgrade the commandlog JPA schema so the mapping table, unique constraint, indexes, and optional interaction-id column exist, include the commandlog JPA module, and then select the existing `PERSISTENT` property value.
+
+Deploy the schema addition before enabling persistent storage. Rollback consists of returning the storage strategy to `IN_MEMORY` and reverting the listener/model/module registrations; the additive mapping table may remain unused or be removed separately according to the application's schema-management policy. No command log entry or imported command data is rewritten.
+
+## Open Questions
+
+None blocking. The ledger's JPA/JDO selection question is resolved by the Causeway 4 module inventory: M3 implements JPA only and does not restore the removed commandlog JDO adapter.
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/proposal.md b/openspec/changes/reconcile-persistent-replay-mapping/proposal.md
new file mode 100644
index 00000000000..dddf035e18e
--- /dev/null
+++ b/openspec/changes/reconcile-persistent-replay-mapping/proposal.md
@@ -0,0 +1,32 @@
+## Why
+
+Causeway 4 can retain replay-result mappings only in memory, so mappings are lost across application restarts and cannot be inspected or managed as durable commandlog data.
+Now that the bookmark-only replay mapping SPI and `PERSISTENT` storage-strategy contract exist, M3 completes that contract using the Causeway 4 commandlog persistence architecture.
+
+## What Changes
+
+- Add an abstract commandlog applib model and repository contract for durable recorded-to-actual bookmark mappings, including the originating command interaction id.
+- Add a persistent replay-mapping listener that looks up stored mappings, records identity and changed results, preserves the first observation, and applies the existing conflict policy.
+- Conditionally provide the persistent listener when storage strategy is `PERSISTENT`, a mapping repository is available, and no application-defined replay-mapping listener exists.
+- Add JPA entity and repository implementations, datastore uniqueness and lookup indexes, module registration, teardown support, and integration tests.
+- Add commandlog menu actions and fallback layout metadata for listing, finding, inspecting, and deleting persisted mappings.
+- Treat commandlog JDO persistence as not applicable on Causeway 4 because that adapter was deliberately removed; do not recreate it as part of M3.
+- Keep replayable-command projections, unified manager behavior, export/import, reachability, workflow, and background-gate capabilities outside this change.
+
+## Capabilities
+
+### New Capabilities
+
+- `persistent-command-replay-mapping`: Defines durable replay-result mapping storage, persistent listener behavior, JPA persistence, conditional wiring, query/menu access, interaction-id retention, and deletion.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- `extensions/core/commandlog/applib`: abstract mapping model and repository, persistent listener, conditional bean factory, menu actions, mixin, layout, and tests.
+- `extensions/core/commandlog/persistence-jpa`: concrete Jakarta Persistence entity and repository, indexes and named queries, module registration, teardown, and integration tests.
+- Existing `causeway.extensions.command-log.replay-result-mapping` configuration and `CommandReplayMappingListener` SPI are reused without changing their public shape.
+- Applications opting into `PERSISTENT` gain durable mapping state and require the commandlog JPA persistence module and its schema update; `IN_MEMORY` remains the compatibility default.
+- Programme context and the explicit Causeway 4 JPA-only adaptation are recorded under `openspec/reconciliation/maintenance-branch/`.
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md b/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
new file mode 100644
index 00000000000..94c990d1c55
--- /dev/null
+++ b/openspec/changes/reconcile-persistent-replay-mapping/specs/persistent-command-replay-mapping/spec.md
@@ -0,0 +1,187 @@
+## ADDED Requirements
+
+### Requirement: Persistent listener records replay-result mappings
+The system SHALL provide a persistent `CommandReplayMappingListener` that stores replay results by recorded result bookmark.
+The persisted mapping SHALL contain the recorded bookmark, actual bookmark, and optional originating command interaction id.
+The listener SHALL persist both identity mappings and mappings whose recorded and actual bookmarks differ.
+When no mapping exists for a recorded bookmark, the listener SHALL persist the first observed actual bookmark and interaction id.
+When the same recorded bookmark and actual bookmark are observed again, the listener SHALL treat the notification as idempotent and MUST NOT replace the original interaction id.
+When a recorded bookmark is observed with a different actual bookmark, the listener SHALL apply the configured conflict policy and MUST NOT replace the persisted actual bookmark or interaction id.
+
+#### Scenario: First changed mapping is persisted
+- **WHEN** replay reports recorded bookmark `demoInvoice:1`, actual bookmark `demoInvoice:2`, and interaction id `11111111-1111-1111-1111-111111111111`
+- **THEN** the persistent listener stores that bookmark mapping and interaction id
+
+#### Scenario: Identity mapping is persisted
+- **WHEN** replay reports recorded and actual bookmark `demoInvoice:1`
+- **THEN** the persistent listener stores the identity mapping
+
+#### Scenario: Repeated mapping is idempotent
+- **GIVEN** recorded bookmark `demoInvoice:1` is stored against actual bookmark `demoInvoice:2` and a first interaction id
+- **WHEN** replay reports the same bookmarks with a later interaction id
+- **THEN** only one mapping remains
+- **AND** the first interaction id is retained
+
+#### Scenario: Strict conflict rejects the replay
+- **GIVEN** recorded bookmark `demoInvoice:1` is stored against actual bookmark `demoInvoice:2`
+- **AND** conflict policy is `THROW_EXCEPTION`
+- **WHEN** replay reports `demoInvoice:1` against `demoInvoice:3`
+- **THEN** the listener throws an exception
+- **AND** the stored actual bookmark remains `demoInvoice:2`
+
+#### Scenario: Lenient conflict keeps the first mapping
+- **GIVEN** recorded bookmark `demoInvoice:1` is stored against actual bookmark `demoInvoice:2`
+- **AND** conflict policy is `LOG_AND_CONTINUE`
+- **WHEN** replay reports `demoInvoice:1` against `demoInvoice:3`
+- **THEN** the listener logs the conflict and returns normally
+- **AND** the stored actual bookmark remains `demoInvoice:2`
+
+### Requirement: Persistent listener remaps replay inputs from stored mappings
+The persistent listener SHALL return the stored actual bookmark when the replay mapping SPI looks up a recorded bookmark with a persisted mapping.
+The listener SHALL return no replacement when no mapping exists.
+The listener MUST NOT resolve either bookmark to a live domain object while performing lookup.
+
+#### Scenario: Stored mapping replaces replay input
+- **GIVEN** recorded bookmark `demoInvoice:1` is stored against actual bookmark `demoInvoice:2`
+- **WHEN** replay looks up `demoInvoice:1`
+- **THEN** the listener returns `demoInvoice:2`
+
+#### Scenario: Stored identity mapping is returned
+- **GIVEN** recorded bookmark `demoInvoice:1` is stored against actual bookmark `demoInvoice:1`
+- **WHEN** replay looks up `demoInvoice:1`
+- **THEN** the listener returns `demoInvoice:1`
+
+#### Scenario: Unknown bookmark is not replaced
+- **WHEN** replay looks up a recorded bookmark with no persisted mapping
+- **THEN** the listener returns no replacement
+
+### Requirement: Applib defines persistence-neutral mapping contracts
+The system SHALL define an abstract commandlog applib mapping type exposing recorded bookmark, actual bookmark, and optional command interaction id.
+The system SHALL define an applib repository contract and reusable repository behavior for finding a mapping by recorded bookmark, finding mappings by actual bookmark, listing all mappings, listing mappings whose bookmarks differ, creating a mapping, deleting one mapping, and deleting all mappings.
+The applib contracts SHALL use `Bookmark` and UUID values without depending on a persistence technology.
+
+#### Scenario: Changed finder excludes identity mappings
+- **GIVEN** stored mappings `demoInvoice:1` to `demoInvoice:1` and `demoInvoice:2` to `demoInvoice:3`
+- **WHEN** the repository lists changed mappings
+- **THEN** only the mapping from `demoInvoice:2` to `demoInvoice:3` is returned
+
+#### Scenario: Reverse finder returns every recorded source
+- **GIVEN** stored mappings `demoInvoice:1` to `demoInvoice:9` and `demoInvoice:2` to `demoInvoice:9`
+- **WHEN** the repository finds mappings by actual bookmark `demoInvoice:9`
+- **THEN** both mappings are returned
+
+### Requirement: JPA module persists replay-result mappings
+The Causeway 4 commandlog JPA module SHALL provide a concrete Jakarta Persistence mapping entity and repository implementing the applib contracts.
+The mapping table SHALL enforce uniqueness of recorded bookmark and SHALL index recorded and actual bookmarks.
+The JPA module SHALL register the entity, repository, and persistent-listener configuration and SHALL include mapping rows in commandlog teardown.
+Creation SHALL flush the new mapping within the replay transaction so persistence failures propagate before replay commits.
+
+#### Scenario: JPA module supplies persistent mapping services
+- **WHEN** the commandlog JPA persistence module is active
+- **THEN** a concrete replay-result mapping repository is available
+- **AND** its mapping entity is included in JPA entity scanning
+
+#### Scenario: Recorded bookmark is unique
+- **GIVEN** a JPA mapping already exists for recorded bookmark `demoInvoice:1`
+- **WHEN** another row is inserted for recorded bookmark `demoInvoice:1`
+- **THEN** datastore uniqueness prevents two authoritative mappings
+
+#### Scenario: Commandlog teardown removes mappings
+- **GIVEN** command log entries and replay-result mappings exist
+- **WHEN** the commandlog JPA teardown fixture runs
+- **THEN** the replay-result mappings are removed along with the commandlog test data
+
+### Requirement: Persistent listener is conditionally enabled
+When `causeway.extensions.command-log.replay-result-mapping.storage-strategy` is `PERSISTENT`, a replay-result mapping repository is available, and no application-defined `CommandReplayMappingListener` bean exists, the system SHALL provide the persistent listener.
+When an application-defined listener exists, the persistent listener MUST back off.
+When storage strategy is not `PERSISTENT` or no mapping repository is available, the system MUST NOT create the persistent listener.
+The in-memory and persistent built-in listeners MUST NOT be active together.
+
+#### Scenario: Persistent listener is selected with JPA repository
+- **GIVEN** storage strategy is `PERSISTENT`
+- **AND** a JPA replay-result mapping repository is available
+- **AND** no custom replay-mapping listener exists
+- **THEN** the persistent replay-mapping listener is provided
+- **AND** the in-memory listener is absent
+
+#### Scenario: Custom listener remains authoritative
+- **GIVEN** storage strategy is `PERSISTENT`
+- **AND** an application-defined replay-mapping listener exists
+- **THEN** the built-in persistent listener is absent
+
+#### Scenario: Persistent strategy without repository creates no built-in
+- **GIVEN** storage strategy is `PERSISTENT`
+- **AND** no replay-result mapping repository is available
+- **THEN** the persistent listener is absent
+
+#### Scenario: In-memory strategy excludes persistent listener
+- **GIVEN** storage strategy is `IN_MEMORY`
+- **THEN** the persistent listener is absent
+
+### Requirement: Persisted mappings are queryable from the commandlog menu
+The commandlog menu SHALL provide actions to list all persisted mappings, list changed mappings, find a mapping by recorded bookmark, and find mappings by actual bookmark.
+The actions SHALL delegate through the applib repository and SHALL be hidden when no replay-result mapping repository is available.
+The all-mappings action SHALL include identity mappings, while the changed-mappings action MUST exclude them.
+
+#### Scenario: User lists all mappings including identity
+- **GIVEN** identity and changed replay-result mappings are persisted
+- **WHEN** the user invokes the all-mappings action
+- **THEN** both identity and changed mappings are returned
+
+#### Scenario: User finds mapping by recorded bookmark
+- **GIVEN** `demoInvoice:1` is mapped to `demoInvoice:2`
+- **WHEN** the user searches for recorded bookmark `demoInvoice:1`
+- **THEN** that mapping is returned
+
+#### Scenario: User finds no recorded mapping
+- **WHEN** the user searches for a recorded bookmark with no persisted mapping
+- **THEN** an empty result is returned
+
+#### Scenario: Mapping finders are hidden without repository
+- **GIVEN** no replay-result mapping repository is available
+- **WHEN** the commandlog menu is rendered
+- **THEN** every persistent-mapping finder action is hidden
+
+### Requirement: Persisted mapping layout exposes audit data
+The system SHALL provide fallback layout metadata for persisted replay-result mappings.
+The layout SHALL display recorded bookmark, actual bookmark, and optional originating command interaction id.
+The mapping title SHALL distinguish the recorded and actual bookmarks.
+
+#### Scenario: User inspects mapping audit data
+- **GIVEN** a persisted mapping has recorded bookmark `demoInvoice:1`, actual bookmark `demoInvoice:2`, and an interaction id
+- **WHEN** the user views the mapping
+- **THEN** the layout displays both bookmarks and the interaction id
+
+### Requirement: Persisted mappings can be deleted without deleting commands
+The system SHALL contribute an idempotent are-you-sure action that deletes one persisted replay-result mapping.
+The commandlog menu SHALL provide an idempotent are-you-sure action that deletes all persisted replay-result mappings and reports the number deleted.
+The bulk action SHALL be hidden when no replay-result mapping repository is available.
+Neither deletion action SHALL delete command log entries or imported replay commands.
+
+#### Scenario: User deletes one mapping
+- **GIVEN** a persisted replay-result mapping exists
+- **WHEN** the user confirms its delete action
+- **THEN** that mapping is removed
+- **AND** command log entries remain unchanged
+
+#### Scenario: User deletes all mappings
+- **GIVEN** two replay-result mappings and command log entries exist
+- **WHEN** the user confirms the delete-all action
+- **THEN** all replay-result mappings are removed
+- **AND** the user is informed that two mappings were deleted
+- **AND** command log entries remain unchanged
+
+#### Scenario: Empty bulk deletion is idempotent
+- **GIVEN** no replay-result mappings exist
+- **WHEN** the user confirms the delete-all action
+- **THEN** the action completes without error and reports zero deletions
+
+### Requirement: Mapping menu actions follow replay workflow order
+The commandlog menu SHALL order existing export and replay actions before persistent-mapping finder actions.
+The menu SHALL order the delete-all mapping action after the mapping finder actions.
+The ordering SHALL be expressed through action layout sequence metadata.
+
+#### Scenario: Mapping management follows replay actions
+- **WHEN** commandlog menu actions are ordered by layout sequence
+- **THEN** export and replay actions appear before mapping finder actions
+- **AND** the delete-all mapping action appears after the mapping finder actions
diff --git a/openspec/changes/reconcile-persistent-replay-mapping/tasks.md b/openspec/changes/reconcile-persistent-replay-mapping/tasks.md
new file mode 100644
index 00000000000..9d9860412fe
--- /dev/null
+++ b/openspec/changes/reconcile-persistent-replay-mapping/tasks.md
@@ -0,0 +1,34 @@
+## 1. Applib persistent mapping model
+
+- [ ] 1.1 Add the abstract `CommandReplayResultMapping` domain type with recorded bookmark, actual bookmark, optional command interaction id, stable logical type, title behavior, and public API documentation.
+- [ ] 1.2 Add the persistence-neutral repository interface and reusable repository base for recorded/actual lookup, all/changed listing, create-and-flush, single removal support, and bulk removal.
+- [ ] 1.3 Add focused applib tests for mapping initialization, title and interaction-id behavior, repository query selection and parameters, creation, and removal.
+
+## 2. Persistent listener and conditional wiring
+
+- [ ] 2.1 Implement `CommandReplayMappingListenerPersistent` against the applib repository using bookmark-only lookup and the existing replay-result-mapping conflict policy.
+- [ ] 2.2 Preserve first-observation semantics for identity and changed mappings, idempotent repeats, conflicting repeats, and originating interaction ids.
+- [ ] 2.3 Add Spring Boot 4 conditional wiring for `PERSISTENT` storage with repository presence and custom-listener back-off, while keeping the in-memory and persistent defaults mutually exclusive.
+- [ ] 2.4 Add listener and application-context tests for lookup, identity mapping, creation, interaction-id retention, both conflict policies, repository absence, custom-listener back-off, and `IN_MEMORY` exclusion.
+
+## 3. Causeway 4 JPA persistence
+
+- [ ] 3.1 Add the Jakarta Persistence replay-result mapping entity with bookmark conversion, optional UUID interaction id, recorded-bookmark uniqueness, recorded/actual indexes, named queries, and Causeway entity integration.
+- [ ] 3.2 Add the concrete JPA repository and register the entity, repository, and persistent-listener configuration with the commandlog JPA module and entity scan.
+- [ ] 3.3 Extend commandlog JPA teardown to remove replay-result mappings without changing command-log entry behavior.
+- [ ] 3.4 Add JPA integration tests for create-and-flush, identity and changed rows, recorded and actual bookmark finders, changed/all listing, uniqueness, interaction-id persistence, and teardown.
+
+## 4. Mapping inspection and deletion UI
+
+- [ ] 4.1 Add optional-repository commandlog menu actions for all mappings, changed mappings, recorded-bookmark lookup, and actual-bookmark lookup, hidden when persistence is unavailable.
+- [ ] 4.2 Add fallback layout and column-order metadata displaying recorded bookmark, actual bookmark, and optional originating interaction id.
+- [ ] 4.3 Add an idempotent are-you-sure entity mixin for deleting one mapping and include it in module registration and fallback layout.
+- [ ] 4.4 Add an idempotent are-you-sure commandlog menu action for deleting all mappings, reporting the deleted count and leaving command log entries untouched.
+- [ ] 4.5 Order export/replay actions before mapping finders and the bulk-delete action after them, with focused menu, layout, visibility, deletion, and ordering tests.
+
+## 5. Documentation and verification
+
+- [ ] 5.1 Document persistent storage activation, the commandlog JPA module/schema prerequisite, custom-listener back-off, first-mapping/conflict semantics, interaction-id retention, management actions, and the Causeway 4 JPA-only adaptation.
+- [ ] 5.2 Run focused commandlog applib and JPA tests with JDK 21, including existing in-memory mapping and replay lifecycle regressions.
+- [ ] 5.3 Run the affected aggregate Maven build, IDE compilation and inspections, strict OpenSpec validation, and repository checks.
+- [ ] 5.4 Confirm the implementation introduces no commandlog JDO module and no P1/P2, R1/R2, E1/W1, or B1/B2 behavior.


(causeway) 12/15: CAUSEWAY-3910: reconcile unified command manager

Posted by da...@apache.org.
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 0448c8cf3846acba74ab90a5e53640823d58c399
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 08:09:16 2026 +0100

    CAUSEWAY-3910: reconcile unified command manager
---
 .../commandlog/applib/app/CommandLogMenu.adoc      |   5 +-
 .../applib/dom/replay/CommandManager.adoc          |  36 ++++
 .../applib/CausewayModuleExtCommandLogApplib.java  |  12 ++
 .../commandlog/applib/app/CommandLogMenu.java      |  30 ++++
 .../commandlog/applib/dom/CommandLogEntry.java     |   1 +
 .../applib/dom/CommandLogEntryRepository.java      |   4 +
 .../dom/CommandLogEntryRepositoryAbstract.java     |  25 +++
 .../commandlog/applib/dom/ReplayState.java         |  11 ++
 .../applib/dom/replay/CommandExportManager.java    |  14 ++
 ...ger#commandsInSequence.columnOrder.fallback.txt |   5 +
 ...ommandManager#excluded.columnOrder.fallback.txt |   5 +
 ...anager#pendingOrFailed.columnOrder.fallback.txt |   5 +
 ...ger#recordedOrReplayed.columnOrder.fallback.txt |   5 +
 .../applib/dom/replay/CommandManager.java          | 184 +++++++++++++++++++++
 .../dom/replay/CommandManager.layout.fallback.xml  |  35 ++++
 .../applib/dom/replay/CommandReplayManager.java    |  14 ++
 .../commandlog/applib/dom/replay/HasBaseline.java} |  22 +--
 ...rRetry.java => HasBaseline_changeBaseline.java} |  42 ++---
 ...eplayOrRetry.java => HasBaseline_nextHour.java} |  42 ++---
 ...yOrRetry.java => HasBaseline_previousHour.java} |  42 ++---
 .../commandlog/applib/dom/replay/HasLimit.java}    |  22 +--
 ...eplayOrRetry.java => HasLimit_changeLimit.java} |  42 ++---
 .../applib/dom/replay/ReplayableCommand.java       |  13 +-
 .../replay/ReplayableCommand_replayOrRetry.java    |   6 +-
 .../commandlog/applib/app/CommandLogMenuTest.java  |  30 ++++
 ...CommandLogEntryRepositoryAdjacentQueryTest.java |  23 +++
 .../dom/replay/CommandManagerCollectionsTest.java  | 146 ++++++++++++++++
 .../replay/CommandManagerCompatibilityTest.java    | 102 ++++++++++++
 .../dom/replay/CommandManagerEligibilityTest.java  |  12 +-
 .../applib/dom/replay/CommandManagerStateTest.java |  93 +++++++++++
 .../replay/CommandRecordingSuppressionTest.java    |   1 +
 .../replay/ReplayableCommandReplayStateTest.java   |  72 ++++++++
 .../commandlog/jpa/dom/CommandLogEntry.java        |   8 +
 .../CommandLogEntryAdjacentQuery_IntegTest.java    |  30 +++-
 .../reconcile-unified-command-manager/design.md    |   2 +-
 .../reconcile-unified-command-manager/tasks.md     |  36 ++--
 36 files changed, 1003 insertions(+), 174 deletions(-)

diff --git a/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/app/CommandLogMenu.adoc b/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/app/CommandLogMenu.adoc
index 8348df94669..cf6f599ac1c 100644
--- a/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/app/CommandLogMenu.adoc
+++ b/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/app/CommandLogMenu.adoc
@@ -3,6 +3,10 @@
 
 This service exposes a set of menu actions to search and list xref:refguide:extensions:index/commandlog/applib/dom/CommandLogEntry.adoc[CommandLogEntry] s, by default under the "Activity" secondary menu.
 
+Its prototyping command-manager action is the primary entry point for command sequence review.
+It opens a unified manager at the start of the current hour with a default page limit of 100.
+The former export-manager and replay-manager launch actions remain registered but hidden so existing custom metadata and direct integrations continue to resolve.
+
 == API
 
 [source,java]
@@ -12,4 +16,3 @@ class CommandLogMenu {
   public static final String LOGICAL_TYPE_NAME;
 }
 ----
-
diff --git a/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/dom/replay/CommandManager.adoc b/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/dom/replay/CommandManager.adoc
new file mode 100644
index 00000000000..0499438cd11
--- /dev/null
+++ b/antora/components/refguide-index/modules/extensions/pages/index/commandlog/applib/dom/replay/CommandManager.adoc
@@ -0,0 +1,36 @@
+= CommandManager
+:Notice: 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 ag [...]
+
+The unified command manager is the primary commandlog surface for reviewing foreground command sequences.
+It is a command-recording-suppressed view model whose bookmark state contains an inclusive baseline timestamp and a page limit, which defaults to 100.
+
+The manager provides four read-only views:
+
+* `commandsInSequence` shows the bounded, eligible, non-excluded foreground sequence.
+* `excluded` shows eligible commands marked `EXCLUDED`.
+* `pendingOrFailed` shows every imported command awaiting replay or retry, including resultless safe actions.
+* `recordedOrReplayed` shows eligible `UNDEFINED`, legacy `EXPORTED`, and successfully replayed `OK` commands.
+
+Replay or retry is available for commands in `PENDING`, `OK`, and `FAILED` states.
+It is unavailable for `UNDEFINED`, `EXPORTED`, and `EXCLUDED` commands.
+Background-completion gating is not part of this manager contract.
+
+Existing `CommandExportManager` and `CommandReplayManager` bookmarks remain valid.
+Both legacy managers provide a safe `openCommandManager` action that transfers their baseline and uses the default limit.
+
+== API
+
+[source,java]
+.CommandManager.java
+----
+class CommandManager implements ViewModel, HasBaseline, HasLimit, CommandRecordingSuppressed {
+  public static final String LOGICAL_TYPE_NAME;
+  public static final int DEFAULT_LIMIT;
+  Timestamp getBaseline();
+  int getLimit();
+  List<ReplayableCommand> getCommandsInSequence();
+  List<ReplayableCommand> getExcluded();
+  List<ReplayableCommand> getPendingOrFailed();
+  List<ReplayableCommand> getRecordedOrReplayed();
+}
+----
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 5b9d3649ed2..e38f4c79afa 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
@@ -43,7 +43,12 @@
 import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_openResultObject;
 import org.apache.causeway.extensions.commandlog.applib.dom.mixins.CommandLogEntry_siblingCommands;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.HasBaseline_changeBaseline;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.HasBaseline_nextHour;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.HasBaseline_previousHour;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.HasLimit_changeLimit;
 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;
@@ -69,6 +74,7 @@
         CommandLogMenu.class,
 
         // viewmodels
+        CommandManager.class,
         CommandReplayManager.class,
         ReplayableCommandParticipant.class,
 
@@ -100,6 +106,12 @@
         CommandReplayManager.excludeSelectedFromReplay.class,
         CommandReplayManager.deleteSelectedSucceededOrExcluded.class,
         CommandReplayManager.deleteSelectedPendingOrFailed.class,
+        CommandExportManager.openCommandManager.class,
+        CommandReplayManager.openCommandManager.class,
+        HasBaseline_changeBaseline.class,
+        HasBaseline_previousHour.class,
+        HasBaseline_nextHour.class,
+        HasLimit_changeLimit.class,
 
         // @Component's
         RunBackgroundCommandsJob.class,
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
index f9942e1f1fc..9740768b415 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenu.java
@@ -53,6 +53,7 @@
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandExportManager;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandReplayManager;
 import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext;
 
@@ -86,6 +87,27 @@ public static abstract class ActionDomainEvent<T>
     private final ReplayContext replayContext;
     private final MessageService messageService;
 
+    @Action(
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = commandManager.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED,
+            restrictTo = RestrictTo.PROTOTYPING,
+            semantics = SemanticsOf.SAFE)
+    @ActionLayout(cssClassFa = "solid list", sequence="49")
+    public class commandManager {
+        public class DomainEvent extends ActionDomainEvent<commandManager> { }
+
+        @MemberSupport public CommandManager act(
+                @ParameterLayout(describedAs = "Only foreground commands at or after this timestamp are shown")
+                final Timestamp baseline) {
+            return new CommandManager(baseline, CommandManager.DEFAULT_LIMIT, replayContext);
+        }
+
+        @MemberSupport public Timestamp defaultBaseline() {
+            return truncatedTo(clockService.getClock().nowAsJavaSqlTimestamp(), ChronoUnit.HOURS);
+        }
+    }
+
     @Action(
             commandPublishing = Publishing.DISABLED,
             domainEvent = activeCommands.DomainEvent.class,
@@ -301,6 +323,10 @@ public class DomainEvent extends ActionDomainEvent<exportManager> { }
             final var now = clockService.getClock().nowAsJavaSqlTimestamp();
             return truncatedTo(now, ChronoUnit.HOURS);
         }
+
+        @MemberSupport public boolean hideAct() {
+            return true;
+        }
     }
 
     @Action(
@@ -332,6 +358,10 @@ public class DomainEvent extends ActionDomainEvent<replayManager> { }
         private CommandReplayManager.importCommands importCommands(final CommandReplayManager commandReplayManager) {
             return factoryService.mixin(CommandReplayManager.importCommands.class, commandReplayManager);
         }
+
+        @MemberSupport public boolean hideAct() {
+            return true;
+        }
     }
 
     private static Timestamp truncatedTo(final Timestamp now, final ChronoUnit chronoUnit) {
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 bab9b2908ea..be54ae87076 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
@@ -149,6 +149,7 @@ public static class Nq {
         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";
+        public static final String FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_THREE_REPLAY_STATES = LOGICAL_TYPE_NAME + ".findForegroundByTimestampAfterAndThreeReplayStates";
 
         public static final String FIND_BACKGROUND_AND_NOT_YET_STARTED  = LOGICAL_TYPE_NAME + ".findBackgroundAndNotYetStarted";
         public static final String FIND_RECENT_BACKGROUND_BY_TARGET     = LOGICAL_TYPE_NAME + ".findRecentBackgroundByTarget";
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 0864421465f..268e7403f1d 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
@@ -138,6 +138,10 @@ default List<CommandLogEntry> findForegroundSinceTimestamp(final Timestamp since
      */
     List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayPendingOrFailed(Timestamp since);
 
+    List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayExcluded(Timestamp since);
+
+    List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(Timestamp since);
+
     /**
      * Command Replay feature: Cannot replay or retry.
      */
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 9a0a03c7496..397d390e794 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
@@ -433,6 +433,17 @@ public List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayPendingOrF
         return findForegroundSinceTimestampWithStates(since, ReplayState.PENDING, ReplayState.FAILED);
     }
 
+    @Override
+    public List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayExcluded(final Timestamp since) {
+        return findForegroundSinceTimestampWithState(since, ReplayState.EXCLUDED);
+    }
+
+    @Override
+    public List<CommandLogEntry> findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(final Timestamp since) {
+        return findForegroundSinceTimestampWithStates(
+                since, ReplayState.UNDEFINED, ReplayState.EXPORTED, ReplayState.OK);
+    }
+
     /**
      * Command Replay feature: Cannot replay or retry.
      */
@@ -458,6 +469,20 @@ private List<CommandLogEntry> findForegroundSinceTimestampWithStates(final Times
                                 .withParameter("replayState2", replayState2)));
     }
 
+    private List<CommandLogEntry> findForegroundSinceTimestampWithStates(
+            final Timestamp from,
+            final ReplayState replayState1,
+            final ReplayState replayState2,
+            final ReplayState replayState3) {
+        return _Casts.uncheckedCast(
+                repositoryService().allMatches(
+                        Query.named(commandLogEntryClass, CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_THREE_REPLAY_STATES)
+                                .withParameter("from", from)
+                                .withParameter("replayState1", replayState1)
+                                .withParameter("replayState2", replayState2)
+                                .withParameter("replayState3", replayState3)));
+    }
+
     private RepositoryService repositoryService() {
         return repositoryServiceProvider.get();
     }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java
index 5be703106dd..2f5d147a3c9 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/ReplayState.java
@@ -63,6 +63,12 @@ public boolean isPendingOrFailed() {
                 || this == ReplayState.FAILED;
     }
 
+    public boolean isReplayable() {
+        return this == ReplayState.PENDING
+                || this == ReplayState.OK
+                || this == ReplayState.FAILED;
+    }
+
     public boolean isOkOrExcluded() {
         return this == ReplayState.OK
                 || this == ReplayState.EXCLUDED;
@@ -85,6 +91,11 @@ public static boolean isPendingOrFailed(final @Nullable ReplayState replayState)
                 && replayState.isPendingOrFailed();
     }
 
+    public static boolean isReplayable(final @Nullable ReplayState replayState) {
+        return replayState != null
+                && replayState.isReplayable();
+    }
+
     public static boolean isOkOrExcluded(final @Nullable ReplayState replayState) {
         return replayState != null
                 && replayState.isOkOrExcluded();
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 b1402719ab8..7ea0678972d 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
@@ -243,6 +243,20 @@ public List<ReplayableCommand> getExported() {
             .collect(Collectors.toList());
     }
 
+    @Action(
+            semantics = SemanticsOf.SAFE,
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = openCommandManager.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED)
+    @ActionLayout(sequence = "3", named = "Open Command Manager")
+    public class openCommandManager {
+        public class DomainEvent extends ActionDomainEvent<openCommandManager> { }
+
+        @MemberSupport public CommandManager act() {
+            return new CommandManager(baseline, CommandManager.DEFAULT_LIMIT, replayContext);
+        }
+    }
+
     @Action(
             restrictTo = RestrictTo.PROTOTYPING,
             choicesFrom = "exported",
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#commandsInSequence.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#commandsInSequence.columnOrder.fallback.txt
new file mode 100644
index 00000000000..1b87450bbbc
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#commandsInSequence.columnOrder.fallback.txt
@@ -0,0 +1,5 @@
+#interactionId
+timestamp
+member
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#excluded.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#excluded.columnOrder.fallback.txt
new file mode 100644
index 00000000000..1b87450bbbc
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#excluded.columnOrder.fallback.txt
@@ -0,0 +1,5 @@
+#interactionId
+timestamp
+member
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#pendingOrFailed.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#pendingOrFailed.columnOrder.fallback.txt
new file mode 100644
index 00000000000..1b87450bbbc
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#pendingOrFailed.columnOrder.fallback.txt
@@ -0,0 +1,5 @@
+#interactionId
+timestamp
+member
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#recordedOrReplayed.columnOrder.fallback.txt b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#recordedOrReplayed.columnOrder.fallback.txt
new file mode 100644
index 00000000000..1b87450bbbc
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager#recordedOrReplayed.columnOrder.fallback.txt
@@ -0,0 +1,5 @@
+#interactionId
+timestamp
+member
+replayState
+hasResult
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.java
new file mode 100644
index 00000000000..8465ed4ccd4
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.java
@@ -0,0 +1,184 @@
+/*
+ *  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.util.List;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.apache.causeway.applib.ViewModel;
+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.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.services.command.CommandRecordingSuppressed;
+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;
+
+@DomainObject(introspection = Introspection.ANNOTATION_REQUIRED)
+@DomainObjectLayout(cssClassFa = "solid list")
+@Named(CommandManager.LOGICAL_TYPE_NAME)
+public class CommandManager implements ViewModel, HasBaseline, HasLimit, CommandRecordingSuppressed {
+
+    public static final String LOGICAL_TYPE_NAME =
+            CausewayModuleExtCommandLogApplib.NAMESPACE + ".CommandManager";
+    public static final int DEFAULT_LIMIT = 100;
+
+    private final Timestamp baseline;
+    private final int limit;
+    private final ReplayContext replayContext;
+
+    public CommandManager(final State state, final ReplayContext replayContext) {
+        this.baseline = state.timestamp();
+        this.limit = normalizeLimit(state.limit());
+        this.replayContext = replayContext;
+    }
+
+    public CommandManager(final Timestamp baseline, final int limit, final ReplayContext replayContext) {
+        this(new State(baseline, limit), replayContext);
+    }
+
+    @Inject
+    public CommandManager(final String memento, final ReplayContext replayContext) {
+        this(State.parseMemento(memento, new State(
+                replayContext.clockService().getClock().nowAsJavaSqlTimestamp(), DEFAULT_LIMIT)), replayContext);
+    }
+
+    @ObjectSupport public String title() {
+        return "Command Manager";
+    }
+
+    @Property
+    @PropertyLayout(describedAs = "Only foreground commands at or after this timestamp are available")
+    @Override public Timestamp getBaseline() {
+        return baseline;
+    }
+
+    @Property
+    @PropertyLayout(describedAs = "Maximum number of commands in the sequence page")
+    @Override public int getLimit() {
+        return limit;
+    }
+
+    @Override
+    @Programmatic
+    public CommandManager withBaseline(final Timestamp newBaseline) {
+        return new CommandManager(newBaseline, limit, replayContext);
+    }
+
+    @Override
+    @Programmatic
+    public CommandManager withLimit(final int newLimit) {
+        return new CommandManager(baseline, newLimit, replayContext);
+    }
+
+    @Collection
+    @CollectionLayout(describedAs = "Eligible foreground commands since the baseline, except excluded commands")
+    public List<ReplayableCommand> getCommandsInSequence() {
+        return replayContext.commandLogEntryRepository().findForegroundSinceTimestamp(baseline, limit).stream()
+                .filter(entry -> entry.getReplayState() != ReplayState.EXCLUDED)
+                .filter(this::isEligible)
+                .map(this::wrap)
+                .toList();
+    }
+
+    @Collection
+    @CollectionLayout(sequence = "2", describedAs = "Eligible commands that have been excluded from replay")
+    public List<ReplayableCommand> getExcluded() {
+        return replayContext.commandLogEntryRepository()
+                .findForegroundSinceTimestampAndWithReplayExcluded(baseline).stream()
+                .filter(this::isEligible)
+                .map(this::wrap)
+                .toList();
+    }
+
+    @Collection
+    @CollectionLayout(describedAs = "Imported commands that can be replayed or retried")
+    public List<ReplayableCommand> getPendingOrFailed() {
+        return replayContext.commandLogEntryRepository()
+                .findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline).stream()
+                .map(this::wrap)
+                .toList();
+    }
+
+    @Collection
+    @CollectionLayout(describedAs = "Commands recorded locally, exported historically, or replayed successfully")
+    public List<ReplayableCommand> getRecordedOrReplayed() {
+        return replayContext.commandLogEntryRepository()
+                .findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(baseline).stream()
+                .filter(this::isEligible)
+                .map(this::wrap)
+                .toList();
+    }
+
+    @Override public String viewModelMemento() {
+        return new State(baseline, limit).toMemento();
+    }
+
+    private boolean isEligible(final CommandLogEntry entry) {
+        return ReplayableCommandEligibility.isEligible(entry, replayContext.applicationFeatureRepository());
+    }
+
+    private ReplayableCommand wrap(final CommandLogEntry entry) {
+        return new ReplayableCommand(entry.getInteractionId(), replayContext);
+    }
+
+    private static int normalizeLimit(final int candidate) {
+        return candidate > 0 ? candidate : DEFAULT_LIMIT;
+    }
+
+    public record State(Timestamp timestamp, int limit) {
+        private static final String DELIMITER = "--";
+
+        public static State parseMemento(final String memento, final State fallback) {
+            if (memento == null || memento.isBlank()) {
+                return fallback;
+            }
+            var delimiterAt = memento.indexOf(DELIMITER);
+            var timestampPart = delimiterAt >= 0 ? memento.substring(0, delimiterAt) : memento;
+            var limitPart = delimiterAt >= 0 ? memento.substring(delimiterAt + DELIMITER.length()) : "";
+            var timestamp = TimestampMarshallUtil.fromString(timestampPart, fallback.timestamp());
+            var limit = parseLimit(limitPart, fallback.limit());
+            return new State(timestamp, limit);
+        }
+
+        private static int parseLimit(final String text, final int fallback) {
+            if (text == null || text.isBlank()) {
+                return fallback;
+            }
+            try {
+                return normalizeLimit(Integer.parseInt(text));
+            } catch (NumberFormatException ex) {
+                return fallback;
+            }
+        }
+
+        public String toMemento() {
+            return TimestampMarshallUtil.toString(timestamp) + DELIMITER + normalizeLimit(limit);
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.layout.fallback.xml b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.layout.fallback.xml
new file mode 100644
index 00000000000..bdd34e0f5d8
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManager.layout.fallback.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?><bs:grid xmlns:bs="https://causeway.apache.org/applib/layout/grid/bootstrap3" 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/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/boot [...]
+    <bs:row>
+        <bs:col span="6">
+            <cpt:domainObject/>
+        </bs:col>
+        <bs:col span="6">
+            <cpt:fieldSet name="Control" id="control">
+                <cpt:property id="baseline">
+                    <cpt:action id="previousHour"/>
+                    <cpt:action id="changeBaseline"/>
+                    <cpt:action id="nextHour"/>
+                </cpt:property>
+                <cpt:property id="limit">
+                    <cpt:action id="changeLimit"/>
+                </cpt:property>
+            </cpt:fieldSet>
+            <cpt:fieldSet name="Other" id="other" unreferencedProperties="true"/>
+        </bs:col>
+    </bs:row>
+    <bs:row>
+        <bs:col span="12">
+            <bs:tabGroup>
+                <bs:tab name="Record/Replay">
+                    <bs:row><bs:col span="12"><cpt:collection defaultView="table" paged="15" id="pendingOrFailed"/></bs:col></bs:row>
+                    <bs:row><bs:col span="12"><cpt:collection defaultView="table" paged="15" id="recordedOrReplayed"/></bs:col></bs:row>
+                </bs:tab>
+                <bs:tab name="Sequence">
+                    <bs:row><bs:col span="12"><cpt:collection defaultView="table" paged="100" id="commandsInSequence"/></bs:col></bs:row>
+                    <bs:row><bs:col span="12"><cpt:collection defaultView="table" paged="15" id="excluded"/></bs:col></bs:row>
+                </bs:tab>
+            </bs:tabGroup>
+        </bs:col>
+    </bs:row>
+    <bs:row><bs:col span="12" unreferencedActions="true"><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/CommandReplayManager.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandReplayManager.java
index 7fee1988904..489bf49b590 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
@@ -328,6 +328,20 @@ public List<ReplayableCommand> getSucceededOrExcluded() {
             .collect(Collectors.toList());
     }
 
+    @Action(
+            semantics = SemanticsOf.SAFE,
+            commandPublishing = Publishing.DISABLED,
+            domainEvent = openCommandManager.DomainEvent.class,
+            executionPublishing = Publishing.DISABLED)
+    @ActionLayout(sequence = "3", named = "Open Command Manager")
+    public class openCommandManager {
+        public class DomainEvent extends ActionDomainEvent<openCommandManager> { }
+
+        @MemberSupport public CommandManager act() {
+            return new CommandManager(baseline, CommandManager.DEFAULT_LIMIT, replayContext);
+        }
+    }
+
     @Action(
             restrictTo = RestrictTo.PROTOTYPING,
             choicesFrom = "succeededOrExcluded",
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline.java
similarity index 54%
copy from extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline.java
index 6d526e2c54c..0cdc8770d74 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline.java
@@ -18,24 +18,16 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
-import org.junit.jupiter.api.Test;
+import java.sql.Timestamp;
 
-import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
-import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
 
-import static org.assertj.core.api.Assertions.assertThat;
+public interface HasBaseline {
 
-class CommandRecordingSuppressionTest {
+    abstract class ActionDomainEvent<T>
+            extends CausewayModuleExtCommandLogApplib.ActionDomainEvent<T> { }
 
-    @Test
-    void commandlogHelperTypesOptOutOfRecording() {
-        assertThat(isSuppressed(CommandExportManager.class)).isTrue();
-        assertThat(isSuppressed(CommandReplayManager.class)).isTrue();
-        assertThat(isSuppressed(ReplayableCommand.class)).isTrue();
-        assertThat(isSuppressed(CommandLogEntry.class)).isTrue();
-    }
+    Timestamp getBaseline();
 
-    private boolean isSuppressed(final Class<?> type) {
-        return CommandRecordingSuppressed.class.isAssignableFrom(type);
-    }
+    HasBaseline withBaseline(Timestamp baseline);
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_changeBaseline.java
similarity index 55%
copy from extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_changeBaseline.java
index f5551b05fcf..a40b55d9d58 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_changeBaseline.java
@@ -18,48 +18,34 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
+import java.sql.Timestamp;
+
 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.PromptStyle;
 import org.apache.causeway.applib.annotation.Publishing;
-import org.apache.causeway.applib.annotation.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
 
 import lombok.RequiredArgsConstructor;
 
 @Action(
-        restrictTo = RestrictTo.PROTOTYPING,
-        semantics = SemanticsOf.NON_IDEMPOTENT,
+        semantics = SemanticsOf.SAFE,
         commandPublishing = Publishing.DISABLED,
-        domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class,
-        executionPublishing = Publishing.DISABLED
-)
-@ActionLayout(
-        sequence = "0.1",
-        cssClassFa = "solid circle-play",
-        cssClass = "btn-primary"
-        //hidden = Where.NOWHERE // show in tables //TODO NPE bug
-)
+        domainEvent = HasBaseline_changeBaseline.DomainEvent.class,
+        executionPublishing = Publishing.DISABLED)
+@ActionLayout(associateWith = "baseline", sequence = "2", promptStyle = PromptStyle.INLINE_AS_IF_EDIT)
 @RequiredArgsConstructor
-public class ReplayableCommand_replayOrRetry {
-
-    public static class DomainEvent extends ReplayableCommand.ActionDomainEvent<ReplayableCommand_replayOrRetry> {
-    }
+public class HasBaseline_changeBaseline {
+    public static class DomainEvent extends HasBaseline.ActionDomainEvent<HasBaseline_changeBaseline> { }
 
-    private final ReplayableCommand replayableCommand;
+    private final HasBaseline hasBaseline;
 
-    @MemberSupport
-    public ReplayableCommand act() {
-        replayableCommand.tryReplayOrRetry();
-        return replayableCommand;
+    @MemberSupport public HasBaseline act(final Timestamp baseline) {
+        return hasBaseline.withBaseline(baseline);
     }
 
-    @MemberSupport
-    public String disableAct() {
-        // previously we restricted this, but there are cases where a
-        // retry of a command that succeeded may be warranted
-        // return replayableCommand.disableReplayOrRetry();
-
-        return null;
+    @MemberSupport public Timestamp defaultBaseline() {
+        return hasBaseline.getBaseline();
     }
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_nextHour.java
similarity index 55%
copy from extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_nextHour.java
index f5551b05fcf..58821ab6152 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_nextHour.java
@@ -18,48 +18,32 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
+import java.sql.Timestamp;
+
 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.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
 
 import lombok.RequiredArgsConstructor;
 
 @Action(
-        restrictTo = RestrictTo.PROTOTYPING,
-        semantics = SemanticsOf.NON_IDEMPOTENT,
+        semantics = SemanticsOf.SAFE,
         commandPublishing = Publishing.DISABLED,
-        domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class,
-        executionPublishing = Publishing.DISABLED
-)
+        domainEvent = HasBaseline_nextHour.DomainEvent.class,
+        executionPublishing = Publishing.DISABLED)
 @ActionLayout(
-        sequence = "0.1",
-        cssClassFa = "solid circle-play",
-        cssClass = "btn-primary"
-        //hidden = Where.NOWHERE // show in tables //TODO NPE bug
-)
+        associateWith = "baseline", sequence = "3",
+        named = "+1 hour", position = ActionLayout.Position.PANEL,
+        describedAs = "Move forward one hour")
 @RequiredArgsConstructor
-public class ReplayableCommand_replayOrRetry {
-
-    public static class DomainEvent extends ReplayableCommand.ActionDomainEvent<ReplayableCommand_replayOrRetry> {
-    }
-
-    private final ReplayableCommand replayableCommand;
-
-    @MemberSupport
-    public ReplayableCommand act() {
-        replayableCommand.tryReplayOrRetry();
-        return replayableCommand;
-    }
+public class HasBaseline_nextHour {
+    public static class DomainEvent extends HasBaseline.ActionDomainEvent<HasBaseline_nextHour> { }
 
-    @MemberSupport
-    public String disableAct() {
-        // previously we restricted this, but there are cases where a
-        // retry of a command that succeeded may be warranted
-        // return replayableCommand.disableReplayOrRetry();
+    private final HasBaseline hasBaseline;
 
-        return null;
+    @MemberSupport public HasBaseline act() {
+        return hasBaseline.withBaseline(Timestamp.from(hasBaseline.getBaseline().toInstant().plusSeconds(3600)));
     }
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_previousHour.java
similarity index 55%
copy from extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_previousHour.java
index f5551b05fcf..48660afc6fd 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasBaseline_previousHour.java
@@ -18,48 +18,32 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
+import java.sql.Timestamp;
+
 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.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
 
 import lombok.RequiredArgsConstructor;
 
 @Action(
-        restrictTo = RestrictTo.PROTOTYPING,
-        semantics = SemanticsOf.NON_IDEMPOTENT,
+        semantics = SemanticsOf.SAFE,
         commandPublishing = Publishing.DISABLED,
-        domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class,
-        executionPublishing = Publishing.DISABLED
-)
+        domainEvent = HasBaseline_previousHour.DomainEvent.class,
+        executionPublishing = Publishing.DISABLED)
 @ActionLayout(
-        sequence = "0.1",
-        cssClassFa = "solid circle-play",
-        cssClass = "btn-primary"
-        //hidden = Where.NOWHERE // show in tables //TODO NPE bug
-)
+        associateWith = "baseline", sequence = "1",
+        named = "-1 hour", position = ActionLayout.Position.PANEL,
+        describedAs = "Move back one hour")
 @RequiredArgsConstructor
-public class ReplayableCommand_replayOrRetry {
-
-    public static class DomainEvent extends ReplayableCommand.ActionDomainEvent<ReplayableCommand_replayOrRetry> {
-    }
-
-    private final ReplayableCommand replayableCommand;
-
-    @MemberSupport
-    public ReplayableCommand act() {
-        replayableCommand.tryReplayOrRetry();
-        return replayableCommand;
-    }
+public class HasBaseline_previousHour {
+    public static class DomainEvent extends HasBaseline.ActionDomainEvent<HasBaseline_previousHour> { }
 
-    @MemberSupport
-    public String disableAct() {
-        // previously we restricted this, but there are cases where a
-        // retry of a command that succeeded may be warranted
-        // return replayableCommand.disableReplayOrRetry();
+    private final HasBaseline hasBaseline;
 
-        return null;
+    @MemberSupport public HasBaseline act() {
+        return hasBaseline.withBaseline(Timestamp.from(hasBaseline.getBaseline().toInstant().minusSeconds(3600)));
     }
 }
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit.java
similarity index 54%
copy from extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit.java
index 6d526e2c54c..06b31a12e5f 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit.java
@@ -18,24 +18,14 @@
  */
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
-import org.junit.jupiter.api.Test;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
 
-import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
-import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntry;
+public interface HasLimit {
 
-import static org.assertj.core.api.Assertions.assertThat;
+    abstract class ActionDomainEvent<T>
+            extends CausewayModuleExtCommandLogApplib.ActionDomainEvent<T> { }
 
-class CommandRecordingSuppressionTest {
+    int getLimit();
 
-    @Test
-    void commandlogHelperTypesOptOutOfRecording() {
-        assertThat(isSuppressed(CommandExportManager.class)).isTrue();
-        assertThat(isSuppressed(CommandReplayManager.class)).isTrue();
-        assertThat(isSuppressed(ReplayableCommand.class)).isTrue();
-        assertThat(isSuppressed(CommandLogEntry.class)).isTrue();
-    }
-
-    private boolean isSuppressed(final Class<?> type) {
-        return CommandRecordingSuppressed.class.isAssignableFrom(type);
-    }
+    HasLimit withLimit(int limit);
 }
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit_changeLimit.java
similarity index 55%
copy from extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
copy to extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit_changeLimit.java
index f5551b05fcf..30029fb835c 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/HasLimit_changeLimit.java
@@ -21,45 +21,33 @@
 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.PromptStyle;
 import org.apache.causeway.applib.annotation.Publishing;
-import org.apache.causeway.applib.annotation.RestrictTo;
 import org.apache.causeway.applib.annotation.SemanticsOf;
 
 import lombok.RequiredArgsConstructor;
 
 @Action(
-        restrictTo = RestrictTo.PROTOTYPING,
-        semantics = SemanticsOf.NON_IDEMPOTENT,
+        semantics = SemanticsOf.SAFE,
         commandPublishing = Publishing.DISABLED,
-        domainEvent = ReplayableCommand_replayOrRetry.DomainEvent.class,
-        executionPublishing = Publishing.DISABLED
-)
-@ActionLayout(
-        sequence = "0.1",
-        cssClassFa = "solid circle-play",
-        cssClass = "btn-primary"
-        //hidden = Where.NOWHERE // show in tables //TODO NPE bug
-)
+        domainEvent = HasLimit_changeLimit.DomainEvent.class,
+        executionPublishing = Publishing.DISABLED)
+@ActionLayout(associateWith = "limit", sequence = "2", promptStyle = PromptStyle.INLINE_AS_IF_EDIT)
 @RequiredArgsConstructor
-public class ReplayableCommand_replayOrRetry {
+public class HasLimit_changeLimit {
+    public static class DomainEvent extends HasLimit.ActionDomainEvent<HasLimit_changeLimit> { }
 
-    public static class DomainEvent extends ReplayableCommand.ActionDomainEvent<ReplayableCommand_replayOrRetry> {
-    }
-
-    private final ReplayableCommand replayableCommand;
+    private final HasLimit hasLimit;
 
-    @MemberSupport
-    public ReplayableCommand act() {
-        replayableCommand.tryReplayOrRetry();
-        return replayableCommand;
+    @MemberSupport public HasLimit act(final int newLimit) {
+        return hasLimit.withLimit(newLimit);
     }
 
-    @MemberSupport
-    public String disableAct() {
-        // previously we restricted this, but there are cases where a
-        // retry of a command that succeeded may be warranted
-        // return replayableCommand.disableReplayOrRetry();
+    @MemberSupport public int defaultNewLimit() {
+        return hasLimit.getLimit();
+    }
 
-        return null;
+    @MemberSupport public String validateNewLimit(final int newLimit) {
+        return newLimit > 0 ? null : "Limit must be positive";
     }
 }
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 9d7edb611bb..39229c84688 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
@@ -111,6 +111,9 @@ final static class CommandRecord {
         boolean canReplayOrRetryOrMarkForExclusion() {
             return replayState.isPendingOrFailed();
         }
+        boolean canReplayOrRetry() {
+            return replayState.isReplayable();
+        }
         public String faQuickIcon() {
             return switch (replayState) {
                 case UNDEFINED -> "solid terminal .col-indigo";
@@ -455,7 +458,7 @@ Try<ReplayableCommand> tryReplayOrRetry() {
         if(disableReplayOrRetry()!=null)
             return Try.success(null); // guard against disallowed invocation
         return commandLogEntry()
-            .filter(ReplayableCommand::canReplayOrRetryOrMarkForExclusion)
+            .filter(ReplayableCommand::canReplayOrRetry)
             .map(commandLogEntry -> tryReplay(
                     replayContext.resultRemappingService().remapped(commandLogEntry.getCommandDto()))
                 .mapSuccessAsNullable(__ -> this))
@@ -465,10 +468,10 @@ Try<ReplayableCommand> tryReplayOrRetry() {
 
     String disableReplayOrRetry() {
         return commandRecord()
-                .map(CommandRecord::canReplayOrRetryOrMarkForExclusion)
+                .map(CommandRecord::canReplayOrRetry)
                 .orElse(false)
                 ? null
-                : "Cannot replay, if neither PENDING nor FAILED";
+                : "Cannot replay, unless PENDING, OK or FAILED";
     }
 
     // -- HELPER
@@ -522,6 +525,10 @@ private static boolean canReplayOrRetryOrMarkForExclusion(final CommandLogEntry
         return ReplayState.isPendingOrFailed(commandLogEntry.getReplayState());
     }
 
+    private static boolean canReplayOrRetry(final CommandLogEntry commandLogEntry) {
+        return ReplayState.isReplayable(commandLogEntry.getReplayState());
+    }
+
     /**
      * Handles the replay error case.
      */
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
index f5551b05fcf..cf28589a175 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommand_replayOrRetry.java
@@ -56,10 +56,6 @@ public ReplayableCommand act() {
 
     @MemberSupport
     public String disableAct() {
-        // previously we restricted this, but there are cases where a
-        // retry of a command that succeeded may be warranted
-        // return replayableCommand.disableReplayOrRetry();
-
-        return null;
+        return replayableCommand.disableReplayOrRetry();
     }
 }
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
index 560fbe92141..7e3ad875556 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/app/CommandLogMenuTest.java
@@ -24,6 +24,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.UUID;
+import java.sql.Timestamp;
+import java.time.Instant;
 
 import org.junit.jupiter.api.Test;
 
@@ -32,16 +34,20 @@
 import org.apache.causeway.applib.annotation.Action;
 import org.apache.causeway.applib.annotation.ActionLayout;
 import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.clock.VirtualClock;
 import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.applib.services.clock.ClockService;
 import org.apache.causeway.applib.services.message.MessageService;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMapping;
 import org.apache.causeway.extensions.commandlog.applib.dom.CommandReplayResultMappingRepository;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.CommandManager;
+import org.apache.causeway.extensions.commandlog.applib.dom.replay.ReplayContext;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class CommandLogMenuTest {
 
@@ -96,6 +102,8 @@ void bulkDeleteIsIdempotentAndReportsCount() {
 
     @Test
     void mappingActionsFollowReplayWorkflowOrder() {
+        assertThat(sequenceOf(CommandLogMenu.commandManager.class))
+                .isLessThan(sequenceOf(CommandLogMenu.exportManager.class));
         assertThat(sequenceOf(CommandLogMenu.exportManager.class))
                 .isLessThan(sequenceOf(CommandLogMenu.replayManager.class));
         assertThat(sequenceOf(CommandLogMenu.replayManager.class))
@@ -110,6 +118,28 @@ void mappingActionsFollowReplayWorkflowOrder() {
                 .isLessThan(sequenceOf(CommandLogMenu.deleteReplayResultMappings.class));
     }
 
+    @Test
+    void unifiedManagerIsPrimaryAndUsesCurrentHourDefaults() {
+        var now = Instant.parse("2026-08-06T10:37:42Z");
+        var clockService = mock(ClockService.class);
+        var clock = mock(VirtualClock.class);
+        when(clock.nowAsJavaSqlTimestamp()).thenReturn(Timestamp.from(now));
+        when(clockService.getClock()).thenReturn(clock);
+        var context = new ReplayContext(null, null, null, mock(CommandLogEntryRepository.class),
+                null, clockService, null, null, null);
+        var menu = new CommandLogMenu(
+                mock(CommandLogEntryRepository.class), Optional.empty(), clockService, context,
+                mock(MessageService.class));
+        var action = menu.new commandManager();
+
+        assertThat(action.defaultBaseline()).isEqualTo(Timestamp.from(Instant.parse("2026-08-06T10:00:00Z")));
+        var manager = action.act(action.defaultBaseline());
+        assertThat(manager.getBaseline()).isEqualTo(action.defaultBaseline());
+        assertThat(manager.getLimit()).isEqualTo(CommandManager.DEFAULT_LIMIT);
+        assertThat(menu.new exportManager().hideAct()).isTrue();
+        assertThat(menu.new replayManager().hideAct()).isTrue();
+    }
+
     private static int sequenceOf(final Class<?> actionClass) {
         return Integer.parseInt(actionClass.getAnnotation(ActionLayout.class).sequence());
     }
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
index a13dc369787..8f47ba601b2 100644
--- 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
@@ -65,6 +65,29 @@ void selectsOrderedForegroundQueriesAndOptionalLimit() {
         assertThat(before.getRange().isUnconstrained()).isTrue();
     }
 
+    @Test
+    void selectsUnifiedManagerReplayStateQueries() {
+        var timestamp = Timestamp.valueOf("2026-08-05 12:00:00");
+
+        repository.findForegroundSinceTimestampAndWithReplayExcluded(timestamp);
+        var excluded = captureLastQuery();
+        assertThat(excluded.getName()).isEqualTo(
+                CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_REPLAY_STATE);
+        assertThat(excluded.getParametersByName())
+                .containsEntry("from", timestamp)
+                .containsEntry("replayState", ReplayState.EXCLUDED);
+
+        repository.findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(timestamp);
+        var recorded = captureLastQuery();
+        assertThat(recorded.getName()).isEqualTo(
+                CommandLogEntry.Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_THREE_REPLAY_STATES);
+        assertThat(recorded.getParametersByName())
+                .containsEntry("from", timestamp)
+                .containsEntry("replayState1", ReplayState.UNDEFINED)
+                .containsEntry("replayState2", ReplayState.EXPORTED)
+                .containsEntry("replayState3", ReplayState.OK);
+    }
+
     @SuppressWarnings({"rawtypes", "unchecked"})
     private NamedQuery<CommandLogEntry> captureLastQuery() {
         var captor = ArgumentCaptor.forClass(Query.class);
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCollectionsTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCollectionsTest.java
new file mode 100644
index 00000000000..da5a16806d7
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCollectionsTest.java
@@ -0,0 +1,146 @@
+/*
+ *  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.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+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.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 CommandManagerCollectionsTest {
+
+    private static final Timestamp BASELINE = Timestamp.valueOf("2026-08-06 10:00:00");
+
+    @Test
+    void collectionsPreserveStateMembershipOrderAndPendingException() {
+        var repository = mock(CommandLogEntryRepository.class);
+        var entries = entriesByState();
+        var all = List.of(
+                entries.get(ReplayState.UNDEFINED),
+                entries.get(ReplayState.EXPORTED),
+                entries.get(ReplayState.PENDING),
+                entries.get(ReplayState.OK),
+                entries.get(ReplayState.FAILED),
+                entries.get(ReplayState.EXCLUDED));
+        when(repository.findForegroundSinceTimestamp(BASELINE, 100)).thenReturn(all);
+        when(repository.findForegroundSinceTimestampAndWithReplayExcluded(BASELINE))
+                .thenReturn(List.of(entries.get(ReplayState.EXCLUDED)));
+        when(repository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(BASELINE))
+                .thenReturn(List.of(entries.get(ReplayState.PENDING), entries.get(ReplayState.FAILED)));
+        when(repository.findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(BASELINE))
+                .thenReturn(List.of(
+                        entries.get(ReplayState.UNDEFINED),
+                        entries.get(ReplayState.EXPORTED),
+                        entries.get(ReplayState.OK)));
+        var manager = new CommandManager(BASELINE, 100, context(repository));
+
+        assertThat(ids(manager.getCommandsInSequence()))
+                .containsExactlyElementsOf(ids(all.subList(0, 5)));
+        assertThat(ids(manager.getExcluded()))
+                .containsExactly(entries.get(ReplayState.EXCLUDED).getInteractionId());
+        assertThat(ids(manager.getPendingOrFailed())).containsExactly(
+                entries.get(ReplayState.PENDING).getInteractionId(),
+                entries.get(ReplayState.FAILED).getInteractionId());
+        assertThat(ids(manager.getRecordedOrReplayed())).containsExactly(
+                entries.get(ReplayState.UNDEFINED).getInteractionId(),
+                entries.get(ReplayState.EXPORTED).getInteractionId(),
+                entries.get(ReplayState.OK).getInteractionId());
+        verify(repository).findForegroundSinceTimestamp(BASELINE, 100);
+        all.forEach(CommandManagerCollectionsTest::verifyUnchanged);
+    }
+
+    @Test
+    void eligibilityAppliesToGeneralCollectionsButPendingBypassesIt() {
+        var repository = mock(CommandLogEntryRepository.class);
+        var ineligibleSafe = CommandManagerEligibilityTest.entry(null);
+        when(ineligibleSafe.getReplayState()).thenReturn(ReplayState.PENDING);
+        when(repository.findForegroundSinceTimestamp(BASELINE, 5)).thenReturn(List.of(ineligibleSafe));
+        when(repository.findForegroundSinceTimestampAndWithReplayExcluded(BASELINE)).thenReturn(List.of(ineligibleSafe));
+        when(repository.findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(BASELINE)).thenReturn(List.of(ineligibleSafe));
+        when(repository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(BASELINE)).thenReturn(List.of(ineligibleSafe));
+        var manager = new CommandManager(BASELINE, 5, CommandManagerEligibilityTest.context(repository));
+
+        assertThat(manager.getCommandsInSequence()).isEmpty();
+        assertThat(manager.getExcluded()).isEmpty();
+        assertThat(manager.getRecordedOrReplayed()).isEmpty();
+        assertThat(manager.getPendingOrFailed()).extracting(ReplayableCommand::interactionId)
+                .containsExactly(ineligibleSafe.getInteractionId());
+        verifyUnchanged(ineligibleSafe);
+    }
+
+    @Test
+    void focusedCollectionsAreNotTruncatedBySequenceLimit() {
+        var repository = mock(CommandLogEntryRepository.class);
+        var first = entriesByState().get(ReplayState.PENDING);
+        var second = entriesByState().get(ReplayState.FAILED);
+        when(repository.findForegroundSinceTimestamp(BASELINE, 1)).thenReturn(List.of(first));
+        when(repository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(BASELINE))
+                .thenReturn(List.of(first, second));
+        var manager = new CommandManager(BASELINE, 1, context(repository));
+
+        assertThat(manager.getCommandsInSequence()).hasSize(1);
+        assertThat(manager.getPendingOrFailed()).hasSize(2);
+        verify(repository).findForegroundSinceTimestamp(BASELINE, 1);
+    }
+
+    private static ReplayContext context(final CommandLogEntryRepository repository) {
+        return new ReplayContext(null, null, null, repository, null, null,
+                new ResultRemappingService(List.of()), null, null);
+    }
+
+    private static Map<ReplayState, CommandLogEntry> entriesByState() {
+        var entries = new EnumMap<ReplayState, CommandLogEntry>(ReplayState.class);
+        for (var state : ReplayState.values()) {
+            var entry = mock(CommandLogEntry.class);
+            when(entry.getInteractionId()).thenReturn(UUID.randomUUID());
+            when(entry.getReplayState()).thenReturn(state);
+            when(entry.getCommandDto()).thenReturn(new CommandDto());
+            entries.put(state, entry);
+        }
+        return entries;
+    }
+
+    private static List<UUID> ids(final List<? extends CommandLogEntry> entries) {
+        return entries.stream().map(CommandLogEntry::getInteractionId).toList();
+    }
+
+    private static List<UUID> ids(final java.util.Collection<ReplayableCommand> commands) {
+        return commands.stream().map(ReplayableCommand::interactionId).toList();
+    }
+
+    private static void verifyUnchanged(final CommandLogEntry entry) {
+        verify(entry, never()).setReplayState(any());
+        verify(entry, never()).setResult(any());
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCompatibilityTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCompatibilityTest.java
new file mode 100644
index 00000000000..caed12164ee
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerCompatibilityTest.java
@@ -0,0 +1,102 @@
+/*
+ *  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 jakarta.inject.Named;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Import;
+
+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.clock.VirtualClock;
+import org.apache.causeway.applib.services.clock.ClockService;
+import org.apache.causeway.extensions.commandlog.applib.CausewayModuleExtCommandLogApplib;
+import org.apache.causeway.extensions.commandlog.applib.dom.CommandLogEntryRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class CommandManagerCompatibilityTest {
+
+    private static final Timestamp BASELINE = Timestamp.valueOf("2026-08-06 10:00:00");
+
+    @Test
+    void legacyMementosRemainLoadableAndNavigateToUnifiedManager() {
+        var context = context();
+        var legacyMemento = TimestampMarshallUtil.toString(BASELINE);
+        var exportManager = new CommandExportManager(legacyMemento, context);
+        var replayManager = new CommandReplayManager(legacyMemento, context);
+
+        assertThat(exportManager.baseline()).isEqualTo(BASELINE);
+        assertThat(replayManager.baseline()).isEqualTo(BASELINE);
+        assertUnified(exportManager.new openCommandManager().act());
+        assertUnified(replayManager.new openCommandManager().act());
+        assertSafeSuppressed(CommandExportManager.openCommandManager.class);
+        assertSafeSuppressed(CommandReplayManager.openCommandManager.class);
+    }
+
+    @Test
+    void logicalTypeAndModuleRegistrationAreStable() {
+        assertThat(CommandManager.class.getAnnotation(Named.class).value())
+                .isEqualTo("causeway.ext.commandLog.CommandManager");
+        assertThat(CommandExportManager.LOGICAL_TYPE_NAME)
+                .isEqualTo("causeway.ext.commandLog.CommandExportManager");
+        assertThat(CommandReplayManager.LOGICAL_TYPE_NAME)
+                .isEqualTo("causeway.ext.commandLog.CommandReplayManager");
+
+        var registered = List.of(CausewayModuleExtCommandLogApplib.class
+                .getAnnotation(Import.class).value());
+        assertThat(registered).contains(
+                CommandManager.class,
+                HasBaseline_changeBaseline.class,
+                HasBaseline_previousHour.class,
+                HasBaseline_nextHour.class,
+                HasLimit_changeLimit.class,
+                CommandExportManager.openCommandManager.class,
+                CommandReplayManager.openCommandManager.class);
+    }
+
+    private static void assertUnified(final CommandManager manager) {
+        assertThat(manager.getBaseline()).isEqualTo(BASELINE);
+        assertThat(manager.getLimit()).isEqualTo(CommandManager.DEFAULT_LIMIT);
+    }
+
+    private static void assertSafeSuppressed(final Class<?> type) {
+        var action = type.getAnnotation(Action.class);
+        assertThat(action.semantics()).isEqualTo(SemanticsOf.SAFE);
+        assertThat(action.commandPublishing()).isEqualTo(Publishing.DISABLED);
+        assertThat(action.executionPublishing()).isEqualTo(Publishing.DISABLED);
+    }
+
+    private static ReplayContext context() {
+        var clockService = mock(ClockService.class);
+        var clock = mock(VirtualClock.class);
+        when(clock.nowAsJavaSqlTimestamp()).thenReturn(Timestamp.from(Instant.parse("2026-08-06T12:00:00Z")));
+        when(clockService.getClock()).thenReturn(clock);
+        return new ReplayContext(null, null, null, mock(CommandLogEntryRepository.class), null,
+                clockService, new ResultRemappingService(List.of()), null, null);
+    }
+}
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
index 15b288f842e..86756f58d65 100644
--- 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
@@ -106,7 +106,17 @@ private static Fixture fixture() {
                 List.of(resultless, resultBearing));
     }
 
-    private static CommandLogEntry entry(final Bookmark result) {
+    static ReplayContext context(final CommandLogEntryRepository repository) {
+        var featureRepository = mock(ApplicationFeatureRepository.class);
+        var feature = mock(ApplicationFeature.class);
+        when(featureRepository.findFeature(any())).thenReturn(feature);
+        when(feature.getActionSemantics()).thenReturn(Optional.of(SemanticsOf.SAFE));
+        return new ReplayContext(
+                null, null, null, repository, null, null,
+                new ResultRemappingService(List.of()), null, featureRepository);
+    }
+
+    static CommandLogEntry entry(final Bookmark result) {
         var dto = new CommandDto();
         dto.setMember(new ActionDto());
         var entry = mock(CommandLogEntry.class);
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerStateTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerStateTest.java
new file mode 100644
index 00000000000..d881706db6a
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandManagerStateTest.java
@@ -0,0 +1,93 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.extensions.commandlog.applib.dom.replay;
+
+import java.sql.Timestamp;
+
+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 static org.assertj.core.api.Assertions.assertThat;
+
+class CommandManagerStateTest {
+
+    private static final Timestamp BASELINE = Timestamp.valueOf("2026-08-06 10:00:00");
+    private static final Timestamp FALLBACK_BASELINE = Timestamp.valueOf("2026-08-06 12:00:00");
+    private static final CommandManager.State FALLBACK =
+            new CommandManager.State(FALLBACK_BASELINE, CommandManager.DEFAULT_LIMIT);
+
+    @Test
+    void canonicalMementoRoundTrips() {
+        var state = new CommandManager.State(BASELINE, 50);
+
+        assertThat(CommandManager.State.parseMemento(state.toMemento(), FALLBACK)).isEqualTo(state);
+        assertThat(state.toMemento()).endsWith("--50");
+    }
+
+    @Test
+    void timestampOnlyMementoUsesDefaultLimit() {
+        var state = CommandManager.State.parseMemento(TimestampMarshallUtil.toString(BASELINE), FALLBACK);
+
+        assertThat(state.timestamp()).isEqualTo(BASELINE);
+        assertThat(state.limit()).isEqualTo(CommandManager.DEFAULT_LIMIT);
+    }
+
+    @Test
+    void malformedComponentsFallBackIndependently() {
+        var validTimestampInvalidLimit = CommandManager.State.parseMemento(
+                TimestampMarshallUtil.toString(BASELINE) + "--not-a-number", FALLBACK);
+        var invalidTimestampValidLimit = CommandManager.State.parseMemento("not-a-timestamp--25", FALLBACK);
+        var nonPositiveLimit = CommandManager.State.parseMemento(
+                TimestampMarshallUtil.toString(BASELINE) + "--0", FALLBACK);
+
+        assertThat(validTimestampInvalidLimit).isEqualTo(new CommandManager.State(BASELINE, 100));
+        assertThat(invalidTimestampValidLimit).isEqualTo(new CommandManager.State(FALLBACK_BASELINE, 25));
+        assertThat(nonPositiveLimit).isEqualTo(new CommandManager.State(BASELINE, 100));
+        assertThat(CommandManager.State.parseMemento("", FALLBACK)).isEqualTo(FALLBACK);
+    }
+
+    @Test
+    void controlsPreserveTheOtherStateAndSuppressPublishing() {
+        var manager = new CommandManager(BASELINE, 50, null);
+
+        assertThat(((CommandManager) new HasBaseline_previousHour(manager).act()).getBaseline())
+                .isEqualTo(Timestamp.valueOf("2026-08-06 09:00:00"));
+        assertThat(((CommandManager) new HasBaseline_nextHour(manager).act()).getLimit()).isEqualTo(50);
+        assertThat(((CommandManager) new HasBaseline_changeBaseline(manager)
+                .act(FALLBACK_BASELINE)).getLimit()).isEqualTo(50);
+        assertThat(((CommandManager) new HasLimit_changeLimit(manager).act(25)).getBaseline())
+                .isEqualTo(BASELINE);
+        assertThat(((CommandManager) manager.withLimit(0)).getLimit()).isEqualTo(100);
+
+        assertSafeSuppressed(HasBaseline_previousHour.class);
+        assertSafeSuppressed(HasBaseline_nextHour.class);
+        assertSafeSuppressed(HasBaseline_changeBaseline.class);
+        assertSafeSuppressed(HasLimit_changeLimit.class);
+    }
+
+    private static void assertSafeSuppressed(final Class<?> type) {
+        var action = type.getAnnotation(Action.class);
+        assertThat(action.semantics()).isEqualTo(SemanticsOf.SAFE);
+        assertThat(action.commandPublishing()).isEqualTo(Publishing.DISABLED);
+        assertThat(action.executionPublishing()).isEqualTo(Publishing.DISABLED);
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
index 6d526e2c54c..766f3000b69 100644
--- a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandRecordingSuppressionTest.java
@@ -31,6 +31,7 @@ class CommandRecordingSuppressionTest {
     void commandlogHelperTypesOptOutOfRecording() {
         assertThat(isSuppressed(CommandExportManager.class)).isTrue();
         assertThat(isSuppressed(CommandReplayManager.class)).isTrue();
+        assertThat(isSuppressed(CommandManager.class)).isTrue();
         assertThat(isSuppressed(ReplayableCommand.class)).isTrue();
         assertThat(isSuppressed(CommandLogEntry.class)).isTrue();
     }
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandReplayStateTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandReplayStateTest.java
new file mode 100644
index 00000000000..913bc2df40a
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/ReplayableCommandReplayStateTest.java
@@ -0,0 +1,72 @@
+/*
+ *  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.stream.Stream;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+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.CommandDto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReplayableCommandReplayStateTest {
+
+    @ParameterizedTest
+    @MethodSource("states")
+    void replayAndExclusionUseIndependentStatePredicates(
+            final ReplayState state,
+            final boolean replayable,
+            final boolean excludable) {
+        var id = UUID.randomUUID();
+        var entry = mock(CommandLogEntry.class);
+        when(entry.getInteractionId()).thenReturn(id);
+        when(entry.getReplayState()).thenReturn(state);
+        when(entry.getCommandDto()).thenReturn(new CommandDto());
+        var repository = mock(CommandLogEntryRepository.class);
+        when(repository.findByInteractionId(id)).thenReturn(Optional.of(entry));
+        var context = new ReplayContext(null, null, null, repository, null, null,
+                new ResultRemappingService(List.of()), null, null);
+        var command = new ReplayableCommand(id, context);
+
+        assertThat(command.disableReplayOrRetry() == null).isEqualTo(replayable);
+        assertThat(command.disableExcludeFromReplay() == null).isEqualTo(excludable);
+        assertThat(new ReplayableCommand_replayOrRetry(command).disableAct() == null).isEqualTo(replayable);
+    }
+
+    private static Stream<Arguments> states() {
+        return Stream.of(
+                Arguments.of(ReplayState.UNDEFINED, false, false),
+                Arguments.of(ReplayState.EXPORTED, false, false),
+                Arguments.of(ReplayState.PENDING, true, true),
+                Arguments.of(ReplayState.OK, true, false),
+                Arguments.of(ReplayState.FAILED, true, true),
+                Arguments.of(ReplayState.EXCLUDED, false, false));
+    }
+}
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 8ef25d85203..8a2ab026971 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
@@ -238,6 +238,14 @@
                   + "   AND cl.timestamp >= :from "
                   + "   AND (cl.replayState = :replayState1 OR cl.replayState = :replayState2) "
                   + " ORDER BY cl.timestamp ASC"),
+    @NamedQuery(
+            name  = Nq.FIND_FOREGROUND_BY_TIMESTAMP_AFTER_AND_THREE_REPLAY_STATES,
+            query = "SELECT cl "
+                  + " FROM CommandLogEntry cl "
+                  + " WHERE cl.executeIn = org.apache.causeway.extensions.commandlog.applib.dom.ExecuteIn.FOREGROUND "
+                  + "   AND cl.timestamp >= :from "
+                  + "   AND (cl.replayState = :replayState1 OR cl.replayState = :replayState2 OR cl.replayState = :replayState3) "
+                  + " ORDER BY cl.timestamp ASC"),
 })
 @Named(CommandLogEntry.LOGICAL_TYPE_NAME)
 @DomainObject(
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
index 8fbc9c758ee..90b66b10b08 100644
--- 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
@@ -70,7 +70,35 @@ void foregroundQueriesUseExpectedDirectionBoundaryAndLimit() {
                 .containsExactly(second, first);
     }
 
+    @Test
+    void unifiedManagerQueriesUseInclusiveBaselineStateSetsAndOrdering() {
+        var before = persist("2026-08-05T09:59:59Z", ExecuteIn.FOREGROUND, ReplayState.EXCLUDED);
+        var undefined = persist("2026-08-05T10:00:00Z", ExecuteIn.FOREGROUND, ReplayState.UNDEFINED);
+        var exported = persist("2026-08-05T10:01:00Z", ExecuteIn.FOREGROUND, ReplayState.EXPORTED);
+        var pending = persist("2026-08-05T10:02:00Z", ExecuteIn.FOREGROUND, ReplayState.PENDING);
+        var ok = persist("2026-08-05T10:03:00Z", ExecuteIn.FOREGROUND, ReplayState.OK);
+        var failed = persist("2026-08-05T10:04:00Z", ExecuteIn.FOREGROUND, ReplayState.FAILED);
+        var excluded = persist("2026-08-05T10:05:00Z", ExecuteIn.FOREGROUND, ReplayState.EXCLUDED);
+        persist("2026-08-05T10:06:00Z", ExecuteIn.BACKGROUND, ReplayState.EXCLUDED);
+        var baseline = timestamp("2026-08-05T10:00:00Z");
+
+        assertThat(repository.findForegroundSinceTimestampAndWithReplayExcluded(baseline))
+                .containsExactly(excluded)
+                .doesNotContain(before);
+        assertThat(repository.findForegroundSinceTimestampAndWithReplayPendingOrFailed(baseline))
+                .containsExactly(pending, failed);
+        assertThat(repository.findForegroundSinceTimestampAndWithReplayRecordedOrReplayed(baseline))
+                .containsExactly(undefined, exported, ok);
+    }
+
     private CommandLogEntry persist(final String instant, final ExecuteIn executeIn) {
+        return persist(instant, executeIn, ReplayState.UNDEFINED);
+    }
+
+    private CommandLogEntry persist(
+            final String instant,
+            final ExecuteIn executeIn,
+            final ReplayState replayState) {
         var entry = new CommandLogEntry();
         entry.setInteractionId(UUID.randomUUID());
         entry.setUsername("tester");
@@ -78,7 +106,7 @@ private CommandLogEntry persist(final String instant, final ExecuteIn executeIn)
         entry.setTarget(Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1"));
         entry.setExecuteIn(executeIn);
         entry.setLogicalMemberIdentifier("demo.Customer#update");
-        entry.setReplayState(ReplayState.UNDEFINED);
+        entry.setReplayState(replayState);
         repository.persist(entry);
         return entry;
     }
diff --git a/openspec/changes/reconcile-unified-command-manager/design.md b/openspec/changes/reconcile-unified-command-manager/design.md
index 008816c307c..789076a7ed8 100644
--- a/openspec/changes/reconcile-unified-command-manager/design.md
+++ b/openspec/changes/reconcile-unified-command-manager/design.md
@@ -40,7 +40,7 @@ Introduce small `HasBaseline` and `HasLimit` contracts and manager mixins for pr
 
 The legacy managers keep their existing controls and mementos. Retrofitting their URLs with a limit was rejected because it would break their compatibility role.
 
-### Define four disjoint review intentions over foreground commands
+### Define four focused review intentions over foreground commands
 
 `commandsInSequence` reads foreground entries at or after the baseline in repository order, applies the page limit at the repository boundary, excludes only `EXCLUDED`, and applies P1 general eligibility. It therefore includes eligible Causeway 4 entries in `UNDEFINED`, `EXPORTED`, `PENDING`, `OK`, and `FAILED` states.
 
diff --git a/openspec/changes/reconcile-unified-command-manager/tasks.md b/openspec/changes/reconcile-unified-command-manager/tasks.md
index 2f68c500bb4..927c40fb826 100644
--- a/openspec/changes/reconcile-unified-command-manager/tasks.md
+++ b/openspec/changes/reconcile-unified-command-manager/tasks.md
@@ -1,35 +1,35 @@
 ## 1. Manager State and Controls
 
-- [ ] 1.1 Add `HasBaseline` and `HasLimit` contracts, tolerant baseline/limit memento parsing, and focused tests for canonical, timestamp-only, malformed, and non-positive state.
-- [ ] 1.2 Add the command-recording-suppressed `CommandManager` view model with logical type, default limit, title, canonical memento, and framework constructor.
-- [ ] 1.3 Add safe publishing-disabled previous-hour, next-hour, change-baseline, and change-limit mixins that preserve the manager's other state component, with unit tests.
+- [x] 1.1 Add `HasBaseline` and `HasLimit` contracts, tolerant baseline/limit memento parsing, and focused tests for canonical, timestamp-only, malformed, and non-positive state.
+- [x] 1.2 Add the command-recording-suppressed `CommandManager` view model with logical type, default limit, title, canonical memento, and framework constructor.
+- [x] 1.3 Add safe publishing-disabled previous-hour, next-hour, change-baseline, and change-limit mixins that preserve the manager's other state component, with unit tests.
 
 ## 2. Persistence-Neutral Collection Queries
 
-- [ ] 2.1 Extend the command-entry repository contract and reusable implementation with excluded and recorded-or-replayed foreground queries while reusing bounded foreground and pending-or-failed reads.
-- [ ] 2.2 Add Jakarta Persistence named-query wiring for `EXCLUDED` and `UNDEFINED`/`EXPORTED`/`OK` reads with established ordering and no schema change.
-- [ ] 2.3 Add repository tests for inclusive baselines, replay-state membership, ordering, and bounded sequence reads.
+- [x] 2.1 Extend the command-entry repository contract and reusable implementation with excluded and recorded-or-replayed foreground queries while reusing bounded foreground and pending-or-failed reads.
+- [x] 2.2 Add Jakarta Persistence named-query wiring for `EXCLUDED` and `UNDEFINED`/`EXPORTED`/`OK` reads with established ordering and no schema change.
+- [x] 2.3 Add repository tests for inclusive baselines, replay-state membership, ordering, and bounded sequence reads.
 
 ## 3. Unified Review Collections
 
-- [ ] 3.1 Implement `commandsInSequence` with repository-side limiting, `EXCLUDED` removal, P1 general eligibility, and replay-context-preserving wrapping.
-- [ ] 3.2 Implement `excluded` and `recordedOrReplayed` with their replay-state sets and P1 general eligibility.
-- [ ] 3.3 Implement `pendingOrFailed` using the direct repository result-to-wrapper path so resultless safe imported work bypasses general eligibility.
-- [ ] 3.4 Add manager collection tests covering all six Causeway 4 replay states, eligibility, ordering, sequence limiting, focused-collection non-limiting, and absence of mutation.
+- [x] 3.1 Implement `commandsInSequence` with repository-side limiting, `EXCLUDED` removal, P1 general eligibility, and replay-context-preserving wrapping.
+- [x] 3.2 Implement `excluded` and `recordedOrReplayed` with their replay-state sets and P1 general eligibility.
+- [x] 3.3 Implement `pendingOrFailed` using the direct repository result-to-wrapper path so resultless safe imported work bypasses general eligibility.
+- [x] 3.4 Add manager collection tests covering all six Causeway 4 replay states, eligibility, ordering, sequence limiting, focused-collection non-limiting, and absence of mutation.
 
 ## 4. Entry Point, Compatibility, and Presentation
 
-- [ ] 4.1 Add the unified prototyping menu action with current-hour baseline and default limit, retain but hide the legacy launch actions, and test menu defaults and visibility.
-- [ ] 4.2 Add safe publishing-disabled `openCommandManager` navigation to both legacy managers while preserving their logical types, timestamp-only constructors, collections, and existing actions; add bookmark/navigation compatibility tests.
-- [ ] 4.3 Register the manager and mixins in the commandlog applib module and add fallback object/collection layouts with only P1/P2 fields and controls.
+- [x] 4.1 Add the unified prototyping menu action with current-hour baseline and default limit, retain but hide the legacy launch actions, and test menu defaults and visibility.
+- [x] 4.2 Add safe publishing-disabled `openCommandManager` navigation to both legacy managers while preserving their logical types, timestamp-only constructors, collections, and existing actions; add bookmark/navigation compatibility tests.
+- [x] 4.3 Register the manager and mixins in the commandlog applib module and add fallback object/collection layouts with only P1/P2 fields and controls.
 
 ## 5. Replay State Policy
 
-- [ ] 5.1 Add a replay-specific state predicate that accepts `PENDING`, `OK`, and `FAILED` and rejects `UNDEFINED`, `EXPORTED`, and `EXCLUDED` without changing exclusion eligibility.
-- [ ] 5.2 Wire the replay-or-retry guard to the new predicate and add parameterized tests for every replay state and the deliberately absent background gate.
+- [x] 5.1 Add a replay-specific state predicate that accepts `PENDING`, `OK`, and `FAILED` and rejects `UNDEFINED`, `EXPORTED`, and `EXCLUDED` without changing exclusion eligibility.
+- [x] 5.2 Wire the replay-or-retry guard to the new predicate and add parameterized tests for every replay state and the deliberately absent background gate.
 
 ## 6. Verification
 
-- [ ] 6.1 Run focused commandlog applib and JPA tests, including metamodel/layout tests for the new view model and compatibility actions.
-- [ ] 6.2 Run the affected commandlog Maven verification suite and confirm the change introduces no datastore migration or JDO dependency.
-- [ ] 6.3 Update affected commandlog reference documentation to describe the unified primary manager, page limit, four collections, legacy compatibility path, and P2 replay-state boundary.
+- [x] 6.1 Run focused commandlog applib and JPA tests, including metamodel/layout tests for the new view model and compatibility actions.
+- [x] 6.2 Run the affected commandlog Maven verification suite and confirm the change introduces no datastore migration or JDO dependency.
+- [x] 6.3 Update affected commandlog reference documentation to describe the unified primary manager, page limit, four collections, legacy compatibility path, and P2 replay-state boundary.


(causeway) 09/15: CAUSEWAY-3910: archive replayable command projection change

Posted by da...@apache.org.
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 6dfc86f127fd3ba5c861fc4c3160b283511f687c
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 06:49:31 2026 +0100

    CAUSEWAY-3910: archive replayable command projection change
---
 .../.openspec.yaml                                                | 0
 .../2026-08-06-reconcile-replayable-command-projection}/design.md | 0
 .../proposal.md                                                   | 0
 .../specs/replayable-command-projection/spec.md                   | 0
 .../2026-08-06-reconcile-replayable-command-projection}/tasks.md  | 0
 .../specs/replayable-command-projection/spec.md                   | 8 +++++++-
 6 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/openspec/changes/reconcile-replayable-command-projection/.openspec.yaml b/openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/.openspec.yaml
similarity index 100%
rename from openspec/changes/reconcile-replayable-command-projection/.openspec.yaml
rename to openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/.openspec.yaml
diff --git a/openspec/changes/reconcile-replayable-command-projection/design.md b/openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/design.md
similarity index 100%
rename from openspec/changes/reconcile-replayable-command-projection/design.md
rename to openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/design.md
diff --git a/openspec/changes/reconcile-replayable-command-projection/proposal.md b/openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/proposal.md
similarity index 100%
rename from openspec/changes/reconcile-replayable-command-projection/proposal.md
rename to openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/proposal.md
diff --git a/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md b/openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
similarity index 100%
copy from openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
copy to openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
diff --git a/openspec/changes/reconcile-replayable-command-projection/tasks.md b/openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/tasks.md
similarity index 100%
rename from openspec/changes/reconcile-replayable-command-projection/tasks.md
rename to openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/tasks.md
diff --git a/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md b/openspec/specs/replayable-command-projection/spec.md
similarity index 97%
rename from openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
rename to openspec/specs/replayable-command-projection/spec.md
index 9b03556ee2d..40ec290dd5c 100644
--- a/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
+++ b/openspec/specs/replayable-command-projection/spec.md
@@ -1,4 +1,10 @@
-## ADDED Requirements
+# Replayable Command Projection Specification
+
+## Purpose
+
+Define replay-useful command eligibility, result presence, bookmark participant projection and remapping, participant object availability and mementos, fallback presentation, and adjacent foreground navigation.
+
+## Requirements
 
 ### Requirement: Replay-useful entries are eligible for command projections
 The system SHALL apply a reusable replayable-command eligibility policy before wrapping command log entries in general command-sequence, review, and adjacent-navigation projections.


(causeway) 07/15: CAUSEWAY-3910: plan replayable command projection reconciliation

Posted by da...@apache.org.
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 4d2031ea6db1fccf38b04e297cc99dcf5428d79b
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 5 22:32:32 2026 +0100

    CAUSEWAY-3910: plan replayable command projection reconciliation
---
 .../.openspec.yaml                                 |   2 +
 .../design.md                                      |  87 ++++++++++
 .../proposal.md                                    |  29 ++++
 .../specs/replayable-command-projection/spec.md    | 189 +++++++++++++++++++++
 .../tasks.md                                       |  35 ++++
 5 files changed, 342 insertions(+)

diff --git a/openspec/changes/reconcile-replayable-command-projection/.openspec.yaml b/openspec/changes/reconcile-replayable-command-projection/.openspec.yaml
new file mode 100644
index 00000000000..1c37182ed64
--- /dev/null
+++ b/openspec/changes/reconcile-replayable-command-projection/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-05
diff --git a/openspec/changes/reconcile-replayable-command-projection/design.md b/openspec/changes/reconcile-replayable-command-projection/design.md
new file mode 100644
index 00000000000..a2fe8caff10
--- /dev/null
+++ b/openspec/changes/reconcile-replayable-command-projection/design.md
@@ -0,0 +1,87 @@
+## Context
+
+P1 is the next dependency-ordered reconciliation slice after D1 and M1-M3. Causeway 4 already has a `ReplayableCommand` view model, separate export and replay managers, bookmark-only replay remapping, recorded command-result bookmarks, and durable replay-result mappings. It does not yet expose the maintenance branch's participant-oriented read model, result-presence indicator, or adjacent navigation.
+
+The maintenance specifications span later concerns as well: eligibility is consumed by the P2 unified manager, known-participant state belongs to R2, replay action background gating belongs to B2, and several workflow actions belong to W1. This change must establish the reusable P1 projection while preserving the current Causeway 4 manager types and their view-model mementos.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Enrich `ReplayableCommand` with non-persisted result presence and recorded/actual participant projections.
+- Represent target, reference-parameter, and result participants using bookmark-portable view models with best-effort local object links.
+- Reuse the M1 mapping SPI for actual-bookmark lookup and preserve the replay-state distinctions in the maintenance behavior.
+- Define reusable wrapping eligibility and apply it to existing general sequence/review collections and adjacent navigation, while retaining every repository result in the pending-or-failed replay queue.
+- Add safe previous/next navigation using existing commandlog ordering and the current replay context.
+- Adapt the maintenance behavior to Causeway 4 APIs, Jakarta, and the current commandlog persistence split.
+
+**Non-Goals:**
+
+- Do not replace or merge `CommandExportManager` and `CommandReplayManager`, change their logical types or mementos, or introduce the P2 baseline-bounded `CommandManager` collections.
+- Do not add participant tracking, `knownParticipants`, export-root/reference-data classification, or reachability validation from R1/R2.
+- Do not change replay/retry action state eligibility; the maintenance action contract remains split across P2 and B2.
+- Do not change YAML export/import, selection validation, exclusion/restoration/deletion/reordering workflows, or background-completion gates from E1, W1, or B1/B2.
+- Do not change replay-result persistence, conflict policy, replay transaction boundaries, or datastore schema.
+- Do not restore the removed commandlog JDO module.
+
+## Decisions
+
+### Treat participants as a derived read model
+
+Add a `ReplayableCommandParticipant` view model with `TARGET`, `PARAMETER`, and `RESULT` roles. `ReplayableCommand` derives rows from its current `CommandLogEntry`: every recorded target, reference-valued action parameter, and recorded result becomes a participant; scalar parameters are ignored. No participant state is persisted.
+
+This keeps the authoritative state in the command DTO, command result bookmark, and replay-mapping listener. Persisting participant rows was rejected because it would duplicate DTO state, introduce synchronization problems, and overlap neither P1 nor M3's intended ownership.
+
+### Resolve actual bookmarks through the existing remapping service
+
+Extend `ReplayContext` with the applib services needed by the projection, notably `BookmarkService` and `ApplicationFeatureRepository`, while continuing to use `ResultRemappingService` as the only mapping facade. Preserve the existing constructor as a compatibility overload while module wiring supplies the new services. Target and reference-parameter participants show an explicit mapping in any replay state; after replay state `OK`, an unmapped bookmark falls back to the recorded bookmar [...]
+
+A result participant always exposes its recorded bookmark, but exposes an actual bookmark only when replay state is `OK`; it then uses an explicit mapping or falls back to the recorded bookmark. This state gate follows the normative maintenance specification even if historical maintenance code could surface a pre-existing result mapping earlier. Direct dependency on the persistent mapping repository was rejected because P1 must work identically with custom, in-memory, and persistent listeners.
+
+### Use identity-only participant mementos and derive on rehydration
+
+Participant mementos contain only the owning interaction id and participant identity: `--target`, `--parameter--<name>`, or `--result`. Rehydration reconstructs the owning `ReplayableCommand`, finds the matching derived participant, and recalculates bookmarks and object links. Recorded or actual bookmarks are not embedded in the memento.
+
+This produces readable, stable links without freezing mutable replay state into URLs. Encoding the full row was rejected because actual mappings can change between requests and bookmarks can contain values awkward for durable mementos.
+
+### Resolve participant objects only from actual bookmarks
+
+The target, argument, and result properties use `BookmarkService` to resolve the participant's actual bookmark, returning no object when the bookmark is absent or unresolved. Resolution is role-specific and does not affect replay state. Recorded bookmarks are never required to resolve for the participant row to exist.
+
+This makes the read model portable between source and replay systems. Resolving the recorded bookmark as a fallback was rejected because it could display the wrong local object after remapping.
+
+### Centralize conservative replayable-command eligibility
+
+Add one reusable eligibility policy that distinguishes safe actions by resolving the entry's logical member identifier through the public `ApplicationFeatureRepository` and reading its action semantics. State-changing commands remain eligible. Safe actions are eligible in general command-sequence/review projections only when the entry stores a result bookmark. If member semantics cannot be resolved, the policy retains the entry rather than hiding potentially state-changing work. Dependin [...]
+
+Existing export and completed/excluded collections apply this policy at their wrapping boundary without changing manager identity or workflow. The pending-or-failed replay collection deliberately bypasses it so imported work remains visible. This is the minimum adapter needed to make P1 behavior observable before P2; introducing the maintenance `CommandManager` or its collection names is deferred.
+
+### Navigate by persistence-neutral foreground ordering
+
+Extend the command-entry repository contract and reusable base with bounded foreground-before and foreground-since queries backed by existing named-query conventions. `ReplayableCommand` scans in repository order for the nearest eligible entry, excluding itself from the forward result, and constructs the adjacent row with the same `ReplayContext`. Safe previous/next mixins are disabled at boundaries and never mutate entries.
+
+Loading a complete manager collection and navigating by list index was rejected because a replayable command can be opened directly and must not depend on a P2 manager instance. Querying belongs behind the existing repository abstraction; JPA receives only the named-query wiring needed for these reads and no schema change.
+
+### Make participants the detailed navigation surface
+
+Update fallback layouts so the replayable command shows `hasResult` and a Participants table before replay controls. The participant layout uses the maintenance contract's three-column recorded-versus-actual presentation: a width-four identity/metadata column, a recorded-side column, and an actual-side column. It shows owning command, role, parameter name, recorded bookmark, role-specific object link, and actual bookmark. Remove the redundant command-level target summary/open-target pres [...]
+
+The module registers the participant type and previous/next mixins explicitly. Known-participant columns are not added; P1 orders `hasResult` so R2 can append `knownParticipants` later without revisiting the contract.
+
+## Risks / Trade-offs
+
+- [Risk] Metamodel lookup cannot classify an imported or removed action. → Treat unresolved semantics conservatively as eligible and test the fallback.
+- [Risk] Adjacent queries can return ineligible safe actions before the true neighbor. → Scan ordered foreground results until the nearest eligible entry is found rather than applying a limit before eligibility filtering.
+- [Risk] Timestamp ties can make adjacency ambiguous. → Preserve the repository's established commandlog ordering and document/test the same ordering contract; do not invent P2 sequence state in this slice.
+- [Risk] Participant object lookup can fail on a replay system. → Keep all bookmark properties visible and object-valued properties optional.
+- [Risk] Adding services to `ReplayContext` affects its constructors and tests. → Retain the existing constructor as a compatibility overload, update the single module bean and focused fixtures, and keep interaction-id-only `ReplayableCommand` mementos.
+- [Trade-off] Existing managers receive small collection-filter adaptations before their P2 replacement. → Limit edits to the shared eligibility boundary and preserve all manager actions, logical types, mementos, and baseline behavior.
+- [Trade-off] Result participants deliberately hide an otherwise known mapping until replay state is `OK`. → Follow the normative maintenance scenario so the UI represents this command's replay outcome rather than global mapping availability.
+
+## Migration Plan
+
+This is an additive applib/read-query change with no datastore migration. Applications keep their existing export and replay manager bookmarks and configuration. Deploy the updated commandlog applib and applicable JPA module together so adjacent-query named queries are registered. Rollback restores the earlier view models and query wiring; persisted command entries and replay-result mappings remain compatible.
+
+## Open Questions
+
+None blocking. P2 retains the open decision about migrating separate manager mementos; R2 retains export-root and known-participant semantics.
diff --git a/openspec/changes/reconcile-replayable-command-projection/proposal.md b/openspec/changes/reconcile-replayable-command-projection/proposal.md
new file mode 100644
index 00000000000..e00497e3c37
--- /dev/null
+++ b/openspec/changes/reconcile-replayable-command-projection/proposal.md
@@ -0,0 +1,29 @@
+## Why
+
+Causeway 4's `ReplayableCommand` still exposes only a thin command wrapper, so users cannot inspect recorded versus actual target, reference-parameter, and result participants or navigate the replay sequence reliably. With portable result metadata and both in-memory and persistent bookmark remapping now reconciled, P1 can add that read model without coupling it to the later command-manager redesign.
+
+## What Changes
+
+- Add a non-persisted result-presence projection and reusable eligibility rules for wrapping replay-useful command log entries, while preserving imported pending-or-failed work.
+- Add `ReplayableCommandParticipant` view models for recorded target, reference-parameter, and result bookmarks, their mapped actual bookmarks, and best-effort local object links.
+- Derive participant data from the owning command and replay-mapping SPI, including stable readable participant mementos that rehydrate rather than store bookmark state.
+- Add previous/next navigation across eligible foreground commands using the existing commandlog ordering and the current replay context.
+- Update module registration, fallback layouts, table column ordering, documentation, and focused tests for the richer projection.
+- Keep the existing export and replay managers and replay/retry action eligibility intact; defer unified manager state/mementos, known-participant reachability, export/import validation, workflow mutations, and background-completion gates to P2 and later slices.
+
+## Capabilities
+
+### New Capabilities
+
+- `replayable-command-projection`: Defines replayable-command eligibility, result presence, participant projection and remapping, participant object availability and mementos, layouts, and adjacent navigation.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- `extensions/core/commandlog/applib`: enriches `ReplayableCommand`, adds the participant view model and navigation mixins, extends `ReplayContext` compatibly, extends persistence-neutral command-entry lookup where needed, adapts existing manager wrapping without replacing either manager, and adds tests and documentation.
+- Existing `CommandReplayMappingListener`, `ResultRemappingService`, `CommandLogEntry`, and `CommandLogEntryRepository` contracts are reused; no replay-result mapping persistence schema changes are required.
+- Existing `CommandExportManager` and `CommandReplayManager` logical types and mementos remain compatible pending P2.
+- No replay/retry state-policy change, reference-data SPI, reachability validator, export/import rewrite, command workflow replacement, or background gate is introduced.
diff --git a/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md b/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
new file mode 100644
index 00000000000..9b03556ee2d
--- /dev/null
+++ b/openspec/changes/reconcile-replayable-command-projection/specs/replayable-command-projection/spec.md
@@ -0,0 +1,189 @@
+## ADDED Requirements
+
+### Requirement: Replay-useful entries are eligible for command projections
+The system SHALL apply a reusable replayable-command eligibility policy before wrapping command log entries in general command-sequence, review, and adjacent-navigation projections.
+State-changing command entries SHALL be eligible subject to the collection's existing replay-state and baseline filters.
+Safe action entries SHALL be eligible for those projections only when they store a non-null result bookmark.
+When action semantics cannot be resolved, the entry SHALL remain eligible.
+Ineligible entries MUST remain persisted and MUST NOT be mutated.
+The existing export and completed-or-excluded manager collections SHALL apply this policy without changing manager logical types, mementos, baselines, or actions.
+
+#### Scenario: State-changing command remains eligible
+- **WHEN** a general command projection considers a state-changing command entry
+- **THEN** the entry remains eligible for `ReplayableCommand` wrapping
+
+#### Scenario: Safe action with result is eligible
+- **WHEN** a general command projection considers a safe action entry with a stored result bookmark
+- **THEN** the entry is eligible for `ReplayableCommand` wrapping
+
+#### Scenario: Safe action without result is omitted without mutation
+- **WHEN** a general command projection considers a safe action entry with no stored result bookmark
+- **THEN** the entry is not wrapped as a `ReplayableCommand`
+- **AND** the command log entry remains persisted and unchanged
+
+#### Scenario: Unknown action semantics are retained
+- **WHEN** the metamodel cannot resolve the semantics of a command entry's action
+- **THEN** the entry remains eligible for `ReplayableCommand` wrapping
+
+### Requirement: Pending replay work bypasses general eligibility
+The existing pending-or-failed replay collection SHALL wrap every command entry returned by its repository query.
+A pending-or-failed safe action entry with no stored result bookmark SHALL remain visible for replay or retry review.
+This exception MUST NOT broaden eligibility in other projections.
+
+#### Scenario: Resultless safe action remains visible as imported work
+- **WHEN** the pending-or-failed repository query returns a safe action entry with no result bookmark
+- **THEN** the pending-or-failed collection wraps it as a `ReplayableCommand`
+
+#### Scenario: Pending exception does not affect general collections
+- **WHEN** the same resultless safe action is considered by a general command-sequence collection
+- **THEN** the general collection does not wrap it
+
+### Requirement: Replayable command reports result presence
+A replayable command SHALL expose a non-persisted Boolean property named `hasResult`.
+The property SHALL be `true` exactly when the wrapped command log entry stores a non-null result bookmark and SHALL otherwise be `false`.
+Reading the property MUST NOT resolve the bookmark and MUST NOT change replay state.
+
+#### Scenario: Stored result reports true without lookup
+- **WHEN** a replayable command wraps an entry with recorded result bookmark `demo.Customer:1`
+- **THEN** `hasResult` is `true`
+- **AND** no bookmark lookup is required
+
+#### Scenario: Missing result reports false
+- **WHEN** a replayable command wraps an entry with no recorded result bookmark
+- **THEN** `hasResult` is `false`
+
+### Requirement: Replayable command derives bookmark participants
+Each replayable command SHALL expose a collection of `ReplayableCommandParticipant` view models.
+The collection SHALL include a `TARGET` participant for every recorded target bookmark, a `PARAMETER` participant for every reference-valued action parameter, and a `RESULT` participant when the command stores a recorded result bookmark.
+Parameter participants SHALL expose their parameter name.
+Non-reference parameters MUST NOT create participants.
+Every participant SHALL expose its role and recorded bookmark without requiring that bookmark to resolve locally.
+Participant derivation MUST NOT persist participant state or mutate the command entry.
+
+#### Scenario: Target, reference parameter, and result become participants
+- **WHEN** a command records target `demo.Order:1`, reference parameter `customer=demo.Customer:1`, scalar parameter `quantity=2`, and result `demo.Invoice:1`
+- **THEN** its participants contain `TARGET`, named `PARAMETER`, and `RESULT` rows for the three bookmarks
+- **AND** no participant is created for `quantity`
+
+#### Scenario: Unresolvable recorded bookmark remains visible
+- **WHEN** a participant's recorded bookmark does not resolve on the local system
+- **THEN** the participant still exposes its role and recorded bookmark
+
+### Requirement: Participant actual bookmarks reflect mapping and replay state
+Target and parameter participants SHALL expose the mapped bookmark whenever replay mapping lookup returns one.
+Before replay state `OK`, an unmapped target or parameter participant SHALL expose no actual bookmark.
+At replay state `OK`, an unmapped target or parameter participant SHALL use its recorded bookmark as the actual bookmark.
+A result participant SHALL expose no actual bookmark before replay state `OK`, even when a mapping for its recorded bookmark exists.
+At replay state `OK`, a result participant SHALL use its mapped bookmark when available and SHALL otherwise use its recorded bookmark.
+The projection SHALL use the configured replay-mapping SPI and MUST NOT depend directly on a persistent mapping repository.
+
+#### Scenario: Pending target displays an explicit mapping
+- **WHEN** a pending command records target `demo.Customer:1` and mapping lookup returns `demo.Customer:2`
+- **THEN** its target participant exposes actual bookmark `demo.Customer:2`
+
+#### Scenario: Pending unmapped parameter has no actual bookmark
+- **WHEN** a pending command records reference parameter `customer=demo.Customer:1` and mapping lookup returns no replacement
+- **THEN** its parameter participant exposes no actual bookmark
+
+#### Scenario: Successful unmapped target falls back to recorded bookmark
+- **WHEN** a successfully replayed command records target `demo.Customer:1` and mapping lookup returns no replacement
+- **THEN** its target participant exposes actual bookmark `demo.Customer:1`
+
+#### Scenario: Pending result does not expose global mapping
+- **WHEN** a pending command records result `demo.Invoice:1` and mapping lookup could return `demo.Invoice:2`
+- **THEN** its result participant exposes no actual bookmark
+
+#### Scenario: Successful result displays mapped bookmark
+- **WHEN** a successfully replayed command records result `demo.Invoice:1` and mapping lookup returns `demo.Invoice:2`
+- **THEN** its result participant exposes actual bookmark `demo.Invoice:2`
+
+#### Scenario: Successful unmapped result falls back to recorded bookmark
+- **WHEN** a successfully replayed command records result `demo.Invoice:1` and mapping lookup returns no replacement
+- **THEN** its result participant exposes actual bookmark `demo.Invoice:1`
+
+### Requirement: Participants expose role-specific local objects best-effort
+A target participant SHALL expose a target object only when its actual bookmark resolves locally.
+A parameter participant SHALL expose an argument object only when its actual bookmark resolves locally.
+A result participant SHALL expose a result object only when its actual bookmark resolves locally.
+All object-valued properties SHALL be optional, role-specific, and independent of replay-state mutation.
+Failure to resolve an actual bookmark MUST NOT hide the recorded or actual bookmark values.
+
+#### Scenario: Mapped target resolves locally
+- **WHEN** a target participant has actual bookmark `demo.Customer:2` and that bookmark resolves locally
+- **THEN** its target property links to the local object
+- **AND** its recorded and actual bookmarks remain visible
+
+#### Scenario: Unresolvable parameter retains audit bookmarks
+- **WHEN** a parameter participant's actual bookmark cannot be resolved locally
+- **THEN** its argument property is empty
+- **AND** its recorded and actual bookmark values remain visible
+
+#### Scenario: Properties are role-specific
+- **WHEN** a result participant is displayed
+- **THEN** target and argument properties are hidden
+- **AND** only its optional result-object property is applicable
+
+### Requirement: Participant mementos encode identity rather than bookmark state
+A participant memento SHALL contain its owning command interaction id and participant identity only.
+A target memento SHALL use `[interactionId]--target`, a parameter memento SHALL use `[interactionId]--parameter--[parameterName]`, and a result memento SHALL use `[interactionId]--result`.
+Mementos MUST NOT contain recorded or actual bookmarks.
+When reconstructed from a memento, a participant SHALL derive its bookmarks and object links from the current owning command and mapping state.
+The framework constructor SHALL accept the memento string before injected services.
+
+#### Scenario: Parameter memento is readable and portable
+- **WHEN** parameter `customer` belongs to command `11111111-1111-1111-1111-111111111111`
+- **THEN** its memento is `11111111-1111-1111-1111-111111111111--parameter--customer`
+- **AND** neither recorded nor actual bookmark is encoded
+
+#### Scenario: Rehydration reflects current mapping
+- **WHEN** a participant is reconstructed from its identity memento after replay mapping state changes
+- **THEN** it exposes bookmarks and object links derived from the current owning command and mapping lookup
+
+### Requirement: Replayable commands navigate between adjacent eligible commands
+A replayable command SHALL expose safe `previous` and `next` actions using foreground commandlog ordering.
+Navigation SHALL skip entries that are ineligible under the general replayable-command policy.
+The returned adjacent command SHALL retain the current replay context.
+The previous action SHALL be disabled when no earlier eligible command exists, and the next action SHALL be disabled when no later eligible command exists.
+Navigation MUST NOT mutate command entries.
+
+#### Scenario: Navigate across an ineligible entry
+- **WHEN** eligible command `A` precedes a resultless safe action and eligible command `B` follows it
+- **AND** the user invokes `next` on `A`
+- **THEN** navigation returns replayable command `B` with the same replay context
+
+#### Scenario: First eligible command has no previous action
+- **WHEN** a replayable command has no earlier eligible foreground command
+- **THEN** its previous action is disabled
+
+#### Scenario: Last eligible command has no next action
+- **WHEN** a replayable command has no later eligible foreground command
+- **THEN** its next action is disabled
+
+### Requirement: Fallback layouts expose the richer read model
+The replayable-command fallback layout SHALL display `hasResult`, place its Participants table before replay controls, and order `hasResult` before any future known-participants column.
+The participant table SHALL order role, parameter name, recorded bookmark, target, argument, result, and actual bookmark.
+The participant object layout SHALL use three width-four columns.
+Its first column SHALL expose the owning replayable command, role, and parameter name in a general fieldset and logical type name in metadata.
+Its second column SHALL expose recorded bookmark, target only for target participants, and argument only for parameter participants.
+Its third column SHALL expose actual bookmark and result only for result participants.
+The participant SHALL provide a human-readable title.
+The participant object form MUST NOT expose its owning interaction id.
+The replayable-command object form SHALL use participants rather than separate target-type, target-id, or command-level open-target presentation for target inspection.
+
+#### Scenario: Replayable command shows participants before controls
+- **WHEN** a replayable command uses fallback layout metadata
+- **THEN** `hasResult` is visible
+- **AND** the Participants table appears before replay controls
+
+#### Scenario: Participant layout separates recorded and actual data
+- **WHEN** a participant uses fallback layout metadata
+- **THEN** three width-four columns separate identity, recorded-side data, and actual-side data
+- **AND** its owning replayable command, role, parameter name, and logical type metadata are displayed in the first column
+- **AND** its recorded bookmark and role-specific target or argument link are displayed in the second column
+- **AND** its actual bookmark and role-specific result link are displayed in the third column
+- **AND** its owning interaction id is not displayed on the object form
+
+#### Scenario: Target inspection uses participant rows
+- **WHEN** a replayable command uses fallback object layout metadata
+- **THEN** separate target-type and target-id properties are not displayed
+- **AND** target navigation is available through the target participant's object link
diff --git a/openspec/changes/reconcile-replayable-command-projection/tasks.md b/openspec/changes/reconcile-replayable-command-projection/tasks.md
new file mode 100644
index 00000000000..65ae697ab24
--- /dev/null
+++ b/openspec/changes/reconcile-replayable-command-projection/tasks.md
@@ -0,0 +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.
+
+## 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.
+
+## 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.
+
+## 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.
+
+## 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.


(causeway) 10/15: CAUSEWAY-3910: update reconciliation ledger for P1

Posted by da...@apache.org.
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 cdcb37a46777b463ff3a454e3ce0b77fa257cdfe
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Aug 6 07:15:29 2026 +0100

    CAUSEWAY-3910: update reconciliation ledger for P1
---
 openspec/reconciliation/maintenance-branch/ledger.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/openspec/reconciliation/maintenance-branch/ledger.md b/openspec/reconciliation/maintenance-branch/ledger.md
index b9c3e5c3e6f..c4e7a266545 100644
--- a/openspec/reconciliation/maintenance-branch/ledger.md
+++ b/openspec/reconciliation/maintenance-branch/ledger.md
@@ -37,7 +37,7 @@
 | CAUSEWAY-4042 | D2 | Configurable command-execution advisor policies reconciled | Adapt | Completed by `reconcile-command-replay-mapping` |
 | CAUSEWAY-4010, 4039 | M1, M2 | Bookmark-only remapping SPI, execution-copy remapping, atomic result observation, and conditional in-memory listener reconciled | Supersede | Completed by `reconcile-command-replay-mapping` |
 | Later maintenance mapping work | M3 | Persistent mapping contracts, listener, management UI, and Jakarta Persistence adapter reconciled; the removed commandlog JDO adapter is not applicable on Causeway 4 | Supersede | Completed by `reconcile-persistent-replay-mapping` |
-| Maintenance consolidated replayable-command specs | P1 | Basic `ReplayableCommand` exists but lacks participants, result presence, actual mappings, and adjacent navigation | Supersede | `reconcile-replayable-command-projection` |
+| Maintenance consolidated replayable-command specs | P1 | Replay-useful eligibility, result presence, bookmark participants, actual mappings, object links, identity mementos, layouts, and adjacent navigation reconciled | Supersede | Completed by `reconcile-replayable-command-projection` |
 | Maintenance consolidated manager specs | P2 | Separate export and replay managers exist | Supersede | `reconcile-unified-command-manager` |
 | CAUSEWAY-4034 and later maintenance specs | R1 | `RefData` and command replay reference-data SPI absent | Adapt | `reconcile-command-reference-data` |
 | Later maintenance export specs | R2 | Baseline-bounded participant reachability validator absent | Supersede | `reconcile-command-export-reachability` |
@@ -55,6 +55,7 @@
 | `reconcile-synthetic-command-navigation` | C3, C4b | `openspec/specs/synthetic-command-navigation/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-synthetic-command-navigation/` | Planning `9194e8317f8`; implementation `42b9ecbb433`; archive `ea0a1b8b4c1`; focused and affected aggregate Maven tests passed |
 | `reconcile-command-replay-mapping` | D2, M1, M2 | `openspec/specs/command-execution-advisor-policy/spec.md`; `openspec/specs/command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-04-reconcile-command-replay-mapping/` | Planning `b9412ee98a7`; implementation `6b4ab64d193`; archive `74ba8b40c0a`; focused and affected aggregate Maven tests passed |
 | `reconcile-persistent-replay-mapping` | M3 | `openspec/specs/persistent-command-replay-mapping/spec.md` | `openspec/changes/archive/2026-08-05-reconcile-persistent-replay-mapping/` | Planning `d88eb297388`; implementation `1990363924f`; archive `d95ee405f68`; focused and affected aggregate Maven tests passed |
+| `reconcile-replayable-command-projection` | P1 | `openspec/specs/replayable-command-projection/spec.md` | `openspec/changes/archive/2026-08-06-reconcile-replayable-command-projection/` | Planning `2e65bb795a7`; implementation `4bc1b204484`; archive `30b53793cf2`; focused and full commandlog reactor Maven tests plus IDE inspection passed |
 
 ## Resolved questions
 
@@ -67,6 +68,7 @@
 | Causeway 4 installs synthetic navigation actions through an `A0_BEFORE_BUILTIN` metamodel post-processor so later standard action processing observes them. | C3, C4b | `reconcile-synthetic-command-navigation/design.md` in the archived change and `SyntheticNavigationActionTest` |
 | Replay mapping policy remains entirely in commandlog applib through a bookmark-only listener SPI and result-remapping service; core runtime services implement only general command-execution advisor policy. | D2, M1, M2 | `reconcile-command-replay-mapping/design.md` in the archived change, `ResultRemappingServiceTest`, and `CommandExecutorInteractionAdvisorTest` |
 | Persistent replay-result mappings use the Causeway 4 commandlog JPA module only; the commandlog JDO adapter was deliberately removed and is not restored. | M3 | `reconcile-persistent-replay-mapping/design.md`, `CommandReplayResultMapping_IntegTest`, and the Causeway 4 commandlog module inventory |
+| Replayable-command participants remain a derived read model; actual bookmarks come through the mapping SPI, result mappings remain gated until replay succeeds, and pending-or-failed imported work bypasses general eligibility. | P1 | `reconcile-replayable-command-projection/design.md`, `ReplayableCommandParticipantTest`, and `CommandManagerEligibilityTest` |
 
 ## Open questions