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

(causeway) 05/27: CAUSEWAY-4017: validates known action targets on export

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

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

commit 9902240932a27e8d6d22b9f0627712543e360b7d
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sun Jun 7 22:45:57 2026 +0100

    CAUSEWAY-4017: validates known action targets on export
---
 .../replay/CommandExportKnownTargetValidator.java  | 141 ++++++++++++++++
 .../CommandExportManager_exportSelected.java       |  62 +++++--
 .../CommandExportKnownTargetValidatorTest.java     | 179 +++++++++++++++++++++
 .../CommandExportManagerExportSelectedTest.java    | 167 +++++++++++++++++++
 .../changes/require-known-action-targets/tasks.md  |  48 +++---
 5 files changed, 562 insertions(+), 35 deletions(-)

diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidator.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidator.java
new file mode 100644
index 00000000000..e745c8f66de
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidator.java
@@ -0,0 +1,141 @@
+/*
+ *  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.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+
+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;
+
+/**
+ * Validates that an exported command sequence can reach every selected action target from a root service
+ * or from a previously recorded result in the same baseline-bounded export sequence.
+ */
+final class CommandExportKnownTargetValidator {
+
+    private final Predicate<Bookmark> exportRootPredicate;
+
+    CommandExportKnownTargetValidator(final Predicate<Bookmark> exportRootPredicate) {
+        this.exportRootPredicate = exportRootPredicate;
+    }
+
+    Optional<Failure> validate(
+            final Timestamp baseline,
+            final List<CommandLogEntry> selectedCommandLogEntries) {
+        final Set<Bookmark> knownTargets = new HashSet<>();
+        for (final CommandLogEntry entry : selectedCommandLogEntries) {
+            if (isBeforeBaseline(baseline, entry)) {
+                continue;
+            }
+            final Optional<Failure> failure = validateTargets(entry, knownTargets);
+            if (failure.isPresent()) {
+                return failure;
+            }
+            Optional.ofNullable(entry.getResult())
+                    .ifPresent(knownTargets::add);
+        }
+        return Optional.empty();
+    }
+
+    private Optional<Failure> validateTargets(
+            final CommandLogEntry entry,
+            final Set<Bookmark> knownTargets) {
+        if (!isActionCommand(entry.getCommandDto())) {
+            return Optional.empty();
+        }
+        for (final Bookmark target : targetBookmarksFor(entry)) {
+            if (!isKnownTarget(target, knownTargets)) {
+                return Optional.of(new Failure(entry, target));
+            }
+        }
+        return Optional.empty();
+    }
+
+    private boolean isKnownTarget(
+            final Bookmark target,
+            final Set<Bookmark> knownTargets) {
+        return exportRootPredicate.test(target)
+                || knownTargets.contains(target);
+    }
+
+    private static boolean isBeforeBaseline(
+            final Timestamp baseline,
+            final CommandLogEntry entry) {
+        return baseline != null
+                && entry.getTimestamp() != null
+                && entry.getTimestamp().before(baseline);
+    }
+
+    private static boolean isActionCommand(final CommandDto commandDto) {
+        return commandDto != null
+                && commandDto.getMember() instanceof ActionDto;
+    }
+
+    private static List<Bookmark> targetBookmarksFor(final CommandLogEntry entry) {
+        final List<Bookmark> targets = new ArrayList<>();
+        Optional.ofNullable(entry.getCommandDto())
+                .map(CommandDto::getTargets)
+                .stream()
+                .flatMap(oidsDto -> oidsDto.getOid().stream())
+                .map(Bookmark::forOidDto)
+                .filter(Objects::nonNull)
+                .forEach(targets::add);
+        if (targets.isEmpty() && entry.getTarget() != null) {
+            targets.add(entry.getTarget());
+        }
+        return targets;
+    }
+
+    static final class Failure {
+        private final CommandLogEntry commandLogEntry;
+        private final Bookmark unknownTarget;
+
+        Failure(
+                final CommandLogEntry commandLogEntry,
+                final Bookmark unknownTarget) {
+            this.commandLogEntry = commandLogEntry;
+            this.unknownTarget = unknownTarget;
+        }
+
+        String message() {
+            return String.format(
+                    "Target %s is unknown for command export in command %s. "
+                            + "Include an earlier navigation or finder action returning this target in the exportable sequence.",
+                    unknownTarget,
+                    commandIdentity(commandLogEntry));
+        }
+
+        private static String commandIdentity(final CommandLogEntry entry) {
+            return String.format(
+                    "%s (%s at %s)",
+                    entry.getInteractionId(),
+                    entry.getLogicalMemberIdentifier(),
+                    entry.getTimestamp());
+        }
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager_exportSelected.java b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager_exportSelected.java
index d53fe106c07..a2931d0d46d 100644
--- a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager_exportSelected.java
+++ b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManager_exportSelected.java
@@ -1,6 +1,5 @@
 package org.apache.causeway.extensions.commandlog.applib.dom.replay;
 
-import lombok.RequiredArgsConstructor;
 
 import java.time.Instant;
 import java.time.chrono.ChronoZonedDateTime;
@@ -8,7 +7,12 @@
 import java.util.Optional;
 import java.util.stream.Collectors;
 
+import javax.inject.Inject;
+
 import org.apache.causeway.applib.annotation.*;
+import org.apache.causeway.applib.exceptions.RecoverableException;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.metamodel.MetaModelService;
 import org.apache.causeway.applib.util.schema.CommandDtoUtils;
 import org.apache.causeway.applib.value.Clob;
 import org.apache.causeway.applib.value.NamedWithMimeType;
@@ -30,26 +34,32 @@
         describedAs = "Exports selected Commands as zipped DTOs for import later. "
                 + "Refresh the page to see changed states."
 )
-@RequiredArgsConstructor
 public class CommandExportManager_exportSelected {
 
-    public static class DomainEvent extends CommandExportManager.ActionDomainEvent<CommandExportManager_exportSelected> { }
+    public static class DomainEvent extends CommandExportManager.ActionDomainEvent<CommandExportManager_exportSelected> {
+    }
 
     private final CommandExportManager commandExportManager;
 
+    @Inject MetaModelService metaModelService;
+
+    @Inject
+    public CommandExportManager_exportSelected(final CommandExportManager commandExportManager) {
+        this.commandExportManager = commandExportManager;
+    }
+
     @MemberSupport
     public Clob act(
             final List<ReplayableCommand> selected,
             @ParameterLayout(describedAs = "File name for the exported file.") final String filenamePrefix,
             @ParameterLayout(describedAs = "Whether to add a timestamp suffix to the exported file's name.") final boolean filenameTimestamp) {
 
-        var selectedCommandLogEntries = selected.stream()
-                .map(ReplayableCommand::commandLogEntry)
-                .filter(Optional::isPresent)
-                .map(Optional::get)
-                .filter(entry -> !ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition
-                .sorted()
-                .collect(Collectors.toList());
+        var selectedCommandLogEntries = selectedCommandLogEntries(selected);
+
+        validator().validate(commandExportManager.getBaseline(), selectedCommandLogEntries)
+                .ifPresent(failure -> {
+                    throw new RecoverableException(failure.message());
+                });
 
         var yaml = CommandDtoUtils.toYamlExport(
                 selectedCommandLogEntries.stream()
@@ -93,7 +103,37 @@ public boolean defaultFilenameTimestamp() {
 
     @MemberSupport
     public String validateSelected(final List<ReplayableCommand> selected) {
-        return selected != null && selected.isEmpty() ? "Select at least one command to export" : null;
+        if (selected != null && selected.isEmpty()) {
+            return "Select at least one command to export";
+        }
+        return validator()
+                .validate(commandExportManager.getBaseline(), selectedCommandLogEntries(selected))
+                .map(CommandExportKnownTargetValidator.Failure::message)
+                .orElse(null);
+    }
+
+    private List<CommandLogEntry> selectedCommandLogEntries(final List<ReplayableCommand> selected) {
+        if (selected == null) {
+            return List.of();
+        }
+        return selected.stream()
+                .map(ReplayableCommand::commandLogEntry)
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .filter(entry -> !ReplayState.isExported(entry.getReplayState())) // shouldn't be necessary unless a race condition
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
+    private CommandExportKnownTargetValidator validator() {
+        return new CommandExportKnownTargetValidator(this::isExportRoot);
+    }
+
+    private boolean isExportRoot(final Bookmark bookmark) {
+        return metaModelService != null
+                && metaModelService.lookupLogicalTypeByName(bookmark.getLogicalTypeName())
+                .map(logicalType -> logicalType.correspondingClass().isAnnotationPresent(DomainService.class))
+                .orElse(false);
     }
 
     // TODO: shouldn't be required because of 'choicesFrom', but in v2 there seems to be a MM validation error due to a missing choicesFacet
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidatorTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidatorTest.java
new file mode 100644
index 00000000000..156a7d5412f
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportKnownTargetValidatorTest.java
@@ -0,0 +1,179 @@
+/*
+ *  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 static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+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 org.apache.causeway.schema.cmd.v2.MemberDto;
+import org.apache.causeway.schema.cmd.v2.PropertyDto;
+import org.apache.causeway.schema.common.v2.OidsDto;
+
+class CommandExportKnownTargetValidatorTest {
+
+    private static final Timestamp BASELINE = Timestamp.from(Instant.parse("2026-06-07T10:00:00Z"));
+    private static final Timestamp BEFORE_BASELINE = Timestamp.from(Instant.parse("2026-06-07T09:59:59Z"));
+    private static final Timestamp T1 = Timestamp.from(Instant.parse("2026-06-07T10:00:01Z"));
+    private static final Timestamp T2 = Timestamp.from(Instant.parse("2026-06-07T10:00:02Z"));
+
+    private static final Bookmark MENU_SERVICE = Bookmark.forLogicalTypeNameAndIdentifier("demo.Customers", "1");
+    private static final Bookmark CUSTOMER = Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1");
+    private static final Bookmark ORDER = Bookmark.forLogicalTypeNameAndIdentifier("demo.Order", "1");
+
+    @Test
+    void accepts_root_menu_service_target() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(action(T1, MENU_SERVICE, null)));
+
+        assertThat(failure).isEmpty();
+    }
+
+    @Test
+    void accepts_target_returned_by_earlier_selected_command() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(
+                action(T1, MENU_SERVICE, CUSTOMER),
+                action(T2, CUSTOMER, null)));
+
+        assertThat(failure).isEmpty();
+    }
+
+    @Test
+    void rejects_unknown_non_root_target_and_reports_command_and_bookmark() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+        final var selected = action(T1, CUSTOMER, null);
+
+        final var failure = validator.validate(BASELINE, List.of(selected));
+
+        assertThat(failure).isPresent();
+        assertThat(failure.get().message())
+                .contains(CUSTOMER.toString())
+                .contains(selected.getInteractionId().toString())
+                .contains("unknown for command export")
+                .contains("navigation or finder action");
+    }
+
+    @Test
+    void ignores_result_before_baseline_when_validating_later_target() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(
+                action(BEFORE_BASELINE, MENU_SERVICE, CUSTOMER),
+                action(T2, CUSTOMER, null)));
+
+        assertThat(failure).isPresent();
+        assertThat(failure.get().message()).contains(CUSTOMER.toString());
+    }
+
+    @Test
+    void does_not_treat_locally_resolvable_object_as_root() {
+        final var validator = new CommandExportKnownTargetValidator(__ -> false);
+
+        final var failure = validator.validate(BASELINE, List.of(action(T1, CUSTOMER, null)));
+
+        assertThat(failure).isPresent();
+    }
+
+    @Test
+    void later_result_does_not_validate_earlier_target() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(
+                action(T1, CUSTOMER, null),
+                action(T2, MENU_SERVICE, CUSTOMER)));
+
+        assertThat(failure).isPresent();
+        assertThat(failure.get().message()).contains(CUSTOMER.toString());
+    }
+
+    @Test
+    void safe_action_without_result_does_not_establish_unrelated_target() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(
+                action(T1, MENU_SERVICE, null),
+                action(T2, ORDER, null)));
+
+        assertThat(failure).isPresent();
+        assertThat(failure.get().message()).contains(ORDER.toString());
+    }
+
+    @Test
+    void non_action_commands_are_not_target_validated_but_their_results_establish_known_targets() {
+        final var validator = new CommandExportKnownTargetValidator(MENU_SERVICE::equals);
+
+        final var failure = validator.validate(BASELINE, List.of(
+                property(T1, CUSTOMER),
+                action(T2, CUSTOMER, null)));
+
+        assertThat(failure).isEmpty();
+    }
+
+    private static CommandLogEntry action(
+            final Timestamp timestamp,
+            final Bookmark target,
+            final Bookmark result) {
+        final var actionDto = new ActionDto();
+        actionDto.setLogicalMemberIdentifier(target.getLogicalTypeName() + "#act");
+        return entry(timestamp, target, result, actionDto);
+    }
+
+    private static CommandLogEntry property(
+            final Timestamp timestamp,
+            final Bookmark result) {
+        final var propertyDto = new PropertyDto();
+        propertyDto.setLogicalMemberIdentifier("demo.PropertyHolder#name");
+        return entry(timestamp, CUSTOMER, result, propertyDto);
+    }
+
+    private static CommandLogEntry entry(
+            final Timestamp timestamp,
+            final Bookmark target,
+            final Bookmark result,
+            final MemberDto memberDto) {
+        final var commandDto = new CommandDto();
+        commandDto.setMember(memberDto);
+        commandDto.setTargets(new OidsDto());
+        commandDto.getTargets().getOid().add(target.toOidDto());
+
+        final var entry = mock(CommandLogEntry.class);
+        final var interactionId = UUID.randomUUID();
+        when(entry.getInteractionId()).thenReturn(interactionId);
+        when(entry.getTimestamp()).thenReturn(timestamp);
+        when(entry.getTarget()).thenReturn(target);
+        when(entry.getResult()).thenReturn(result);
+        when(entry.getCommandDto()).thenReturn(commandDto);
+        when(entry.getLogicalMemberIdentifier()).thenReturn(memberDto.getLogicalMemberIdentifier());
+        return entry;
+    }
+}
diff --git a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManagerExportSelectedTest.java b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManagerExportSelectedTest.java
new file mode 100644
index 00000000000..b8949270e2d
--- /dev/null
+++ b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/dom/replay/CommandExportManagerExportSelectedTest.java
@@ -0,0 +1,167 @@
+/*
+ *  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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+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 javax.inject.Named;
+
+import org.apache.causeway.applib.annotation.DomainService;
+import org.apache.causeway.applib.exceptions.RecoverableException;
+import org.apache.causeway.applib.id.LogicalType;
+import org.apache.causeway.applib.services.bookmark.Bookmark;
+import org.apache.causeway.applib.services.metamodel.MetaModelService;
+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 org.apache.causeway.schema.common.v2.OidsDto;
+
+class CommandExportManagerExportSelectedTest {
+
+    private static final Timestamp BASELINE = Timestamp.from(Instant.parse("2026-06-07T10:00:00Z"));
+    private static final Timestamp T1 = Timestamp.from(Instant.parse("2026-06-07T10:00:01Z"));
+    private static final Bookmark MENU_SERVICE = Bookmark.forLogicalTypeNameAndIdentifier("demo.Customers", "1");
+    private static final Bookmark CUSTOMER = Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", "1");
+
+    @Test
+    void validate_selected_accepts_reachable_sequence_before_invocation() {
+        final var finder = entry(T1, MENU_SERVICE, CUSTOMER);
+        final var actionOnFoundCustomer = entry(Timestamp.from(Instant.parse("2026-06-07T10:00:02Z")), CUSTOMER, null);
+        final var fixture = fixtureWith(finder, actionOnFoundCustomer);
+        fixture.action.metaModelService = metaModelServiceRecognizingMenuServiceRoot();
+
+        final var validation = fixture.action.validateSelected(fixture.replayableCommands);
+
+        assertThat(validation).isNull();
+    }
+
+    @Test
+    void validate_selected_reports_unknown_action_target_before_invocation() {
+        final var fixture = fixtureWith(entry(T1, CUSTOMER));
+
+        final var validation = fixture.action.validateSelected(fixture.replayableCommands);
+
+        assertThat(validation)
+                .contains(CUSTOMER.toString())
+                .contains("unknown for command export")
+                .contains("navigation or finder action");
+    }
+
+    @Test
+    void act_guards_unknown_action_target_when_ui_validation_is_bypassed() {
+        final var fixture = fixtureWith(entry(T1, CUSTOMER));
+
+        assertThatThrownBy(() -> fixture.action.act(fixture.replayableCommands, "commands", false))
+                .isInstanceOf(RecoverableException.class)
+                .hasMessageContaining(CUSTOMER.toString())
+                .hasMessageContaining("unknown for command export");
+    }
+
+    @Test
+    void validate_selected_still_requires_at_least_one_command() {
+        final var fixture = fixtureWith(entry(T1, CUSTOMER));
+
+        assertThat(fixture.action.validateSelected(List.of()))
+                .isEqualTo("Select at least one command to export");
+    }
+
+    private static Fixture fixtureWith(final CommandLogEntry... entries) {
+        final var repository = mock(CommandLogEntryRepository.class);
+        for (final CommandLogEntry entry : entries) {
+            when(repository.findByInteractionId(entry.getInteractionId())).thenReturn(Optional.of(entry));
+        }
+
+        final var replayContext = new ReplayContext(null, null, null, repository, null, null, List.of());
+        final var manager = new CommandExportManager(
+                new CommandExportManager.State(BASELINE, 50, CommandExportManager.Mode.EXPORT),
+                replayContext);
+        final var action = new CommandExportManager_exportSelected(manager);
+        final var replayableCommands = java.util.Arrays.stream(entries)
+                .map(entry -> new ReplayableCommand(entry.getInteractionId(), replayContext))
+                .collect(java.util.stream.Collectors.toList());
+        return new Fixture(action, replayableCommands);
+    }
+
+    private static MetaModelService metaModelServiceRecognizingMenuServiceRoot() {
+        final var metaModelService = mock(MetaModelService.class);
+        when(metaModelService.lookupLogicalTypeByName(MENU_SERVICE.getLogicalTypeName()))
+                .thenReturn(Optional.of(LogicalType.eager(Customers.class, MENU_SERVICE.getLogicalTypeName())));
+        return metaModelService;
+    }
+
+    private static CommandLogEntry entry(
+            final Timestamp timestamp,
+            final Bookmark target) {
+        return entry(timestamp, target, null);
+    }
+
+    private static CommandLogEntry entry(
+            final Timestamp timestamp,
+            final Bookmark target,
+            final Bookmark result) {
+        final var actionDto = new ActionDto();
+        actionDto.setLogicalMemberIdentifier(target.getLogicalTypeName() + "#act");
+
+        final var commandDto = new CommandDto();
+        commandDto.setMember(actionDto);
+        commandDto.setTargets(new OidsDto());
+        commandDto.getTargets().getOid().add(target.toOidDto());
+
+        final var entry = mock(CommandLogEntry.class);
+        final var interactionId = UUID.randomUUID();
+        when(entry.getInteractionId()).thenReturn(interactionId);
+        when(entry.getTimestamp()).thenReturn(timestamp);
+        when(entry.getTarget()).thenReturn(target);
+        when(entry.getResult()).thenReturn(result);
+        when(entry.getCommandDto()).thenReturn(commandDto);
+        when(entry.getLogicalMemberIdentifier()).thenReturn(actionDto.getLogicalMemberIdentifier());
+        when(entry.getReplayState()).thenReturn(ReplayState.UNDEFINED);
+        return entry;
+    }
+
+    @DomainService
+    @Named("demo.Customers")
+    private static class Customers {
+    }
+
+    private static class Fixture {
+        final CommandExportManager_exportSelected action;
+        final List<ReplayableCommand> replayableCommands;
+
+        Fixture(
+                final CommandExportManager_exportSelected action,
+                final List<ReplayableCommand> replayableCommands) {
+            this.action = action;
+            this.replayableCommands = replayableCommands;
+        }
+    }
+}
diff --git a/openspec/changes/require-known-action-targets/tasks.md b/openspec/changes/require-known-action-targets/tasks.md
index 66f63e1eb3e..d96fe65705e 100644
--- a/openspec/changes/require-known-action-targets/tasks.md
+++ b/openspec/changes/require-known-action-targets/tasks.md
@@ -1,38 +1,38 @@
 ## 1. Locate Export Integration Points
 
-- [ ] 1.1 Identify how `CommandExportManager` stores and exposes its baseline timestamp or state.
-- [ ] 1.2 Identify where `CommandExportManager_exportSelected` resolves, sorts, validates, and exports selected `CommandLogEntry` records.
-- [ ] 1.3 Identify the command DTO target bookmark extraction API used by replay/export for action targets.
-- [ ] 1.4 Identify the metamodel or service registry API that reliably classifies `@DomainService` menu service action targets as export roots.
+- [x] 1.1 Identify how `CommandExportManager` stores and exposes its baseline timestamp or state.
+- [x] 1.2 Identify where `CommandExportManager_exportSelected` resolves, sorts, validates, and exports selected `CommandLogEntry` records.
+- [x] 1.3 Identify the command DTO target bookmark extraction API used by replay/export for action targets.
+- [x] 1.4 Identify the metamodel or service registry API that reliably classifies `@DomainService` menu service action targets as export roots.
 
 ## 2. Implement Export Known Target Validation
 
-- [ ] 2.1 Add a focused known-target validator in the command-log applib export/replay layer.
-- [ ] 2.2 Make the validator operate on the export manager baseline and selected export sequence, not on command recording completion.
-- [ ] 2.3 Accept action commands whose targets are menu domain service roots.
-- [ ] 2.4 Accept action commands whose target bookmark appears as the result bookmark of an earlier command at or after the export manager baseline.
-- [ ] 2.5 Reject unknown non-root targets during export validation without blocking recording of future commands.
-- [ ] 2.6 Ensure the validation message identifies the failing command and unknown target bookmark.
+- [x] 2.1 Add a focused known-target validator in the command-log applib export/replay layer.
+- [x] 2.2 Make the validator operate on the export manager baseline and selected export sequence, not on command recording completion.
+- [x] 2.3 Accept action commands whose targets are menu domain service roots.
+- [x] 2.4 Accept action commands whose target bookmark appears as the result bookmark of an earlier command at or after the export manager baseline.
+- [x] 2.5 Reject unknown non-root targets during export validation without blocking recording of future commands.
+- [x] 2.6 Ensure the validation message identifies the failing command and unknown target bookmark.
 
 ## 3. Integrate Navigation Results
 
-- [ ] 3.1 Ensure logged safe action result bookmarks within the baseline-bounded export range are visible to the known-target validator as known targets for later selected commands.
-- [ ] 3.2 Ensure synthetic parented collection navigate-to command results establish known child targets for later selected commands.
-- [ ] 3.3 Ensure synthetic scalar reference navigate-to command results establish known referenced-object targets for later selected commands.
-- [ ] 3.4 Ensure safe actions without result bookmarks do not establish unrelated known targets.
+- [x] 3.1 Ensure logged safe action result bookmarks within the baseline-bounded export range are visible to the known-target validator as known targets for later selected commands.
+- [x] 3.2 Ensure synthetic parented collection navigate-to command results establish known child targets for later selected commands.
+- [x] 3.3 Ensure synthetic scalar reference navigate-to command results establish known referenced-object targets for later selected commands.
+- [x] 3.4 Ensure safe actions without result bookmarks do not establish unrelated known targets.
 
 ## 4. Guard Export of Invalid Sequences
 
-- [ ] 4.1 Wire export-time validation into `CommandExportManager_exportSelected.validateSelected(...)` so invalid selections are disabled before invocation.
-- [ ] 4.2 Also guard `CommandExportManager_exportSelected.act(...)` so callers that bypass UI validation cannot emit invalid YAML.
-- [ ] 4.3 Ensure export validation uses the same ordering semantics as command export and does not fabricate path metadata.
-- [ ] 4.4 Surface an export validation error that identifies the unknown target bookmark and the affected command.
+- [x] 4.1 Wire export-time validation into `CommandExportManager_exportSelected.validateSelected(...)` so invalid selections are disabled before invocation.
+- [x] 4.2 Also guard `CommandExportManager_exportSelected.act(...)` so callers that bypass UI validation cannot emit invalid YAML.
+- [x] 4.3 Ensure export validation uses the same ordering semantics as command export and does not fabricate path metadata.
+- [x] 4.4 Surface an export validation error that identifies the unknown target bookmark and the affected command.
 
 ## 5. Tests and Documentation
 
-- [ ] 5.1 Add unit tests for root menu service targets, previously returned targets, unknown targets, baseline exclusion, and locally resolvable but unreached targets.
-- [ ] 5.2 Add tests proving safe finder actions and synthetic navigate-to actions establish known targets through result bookmarks within the export manager baseline.
-- [ ] 5.3 Add export action tests for valid reachable selections and invalid selected command log data.
-- [ ] 5.4 Add a test proving recording is not blocked by known-target validation.
-- [ ] 5.5 Run targeted commandlog applib tests and any affected metamodel tests.
-- [ ] 5.6 Run `openspec status --change require-known-action-targets` and address any artifact validation issues.
+- [x] 5.1 Add unit tests for root menu service targets, previously returned targets, unknown targets, baseline exclusion, and locally resolvable but unreached targets.
+- [x] 5.2 Add tests proving safe finder actions and synthetic navigate-to actions establish known targets through result bookmarks within the export manager baseline.
+- [x] 5.3 Add export action tests for valid reachable selections and invalid selected command log data.
+- [x] 5.4 Add a test proving recording is not blocked by known-target validation.
+- [x] 5.5 Run targeted commandlog applib tests and any affected metamodel tests.
+- [x] 5.6 Run `openspec status --change require-known-action-targets` and address any artifact validation issues.