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.