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

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


The following commit(s) were added to refs/heads/main by this push:
     new 350f629d651 CAUSEWAY-3883: re-implements async invocation
350f629d651 is described below

commit 350f629d6517fe67b527eb2f8fd9e4181ace2825
Author: Andi Huber <[email protected]>
AuthorDate: Thu Jun 26 11:43:48 2025 +0200

    CAUSEWAY-3883: re-implements async invocation
---
 api/applib/src/main/java/module-info.java          |   1 -
 .../applib/services/wrapper/WrapperFactory.java    | 138 +++-----
 .../services/wrapper/callable/AsyncCallable.java   | 114 ------
 .../services/wrapper/control/AsyncControl.java     | 157 +++------
 .../services/wrapper/control/SyncControl.java      |  76 ++--
 .../services/wrapper/events/ParseValueEvent.java   |  62 ----
 .../wrapper/control/AsyncControl_Test.java         |  12 +-
 .../services/wrapper/control/SyncControl_Test.java |  12 +-
 .../_testing/WrapperFactory_forTesting.java        |  10 +-
 .../runtime/wrap/WrapperInvocationHandler.java     |   9 +-
 .../causeway/core/runtime/wrap/WrappingObject.java |   4 +-
 .../wrapper/AsyncExecutorService.java              | 134 +++++++
 .../wrapper/AsyncProxyInternal.java                |  51 +++
 .../wrapper/WrapperFactoryDefault.java             | 387 ++-------------------
 .../wrapper/handlers/CommandRecord.java            |  31 ++
 .../wrapper/handlers/CommandRecordFactory.java     |  52 +++
 .../handlers/DomainObjectInvocationHandler.java    |  80 +++--
 .../wrapper/handlers/ProxyGenerator.java           |  11 +-
 .../wrapper/WrapperFactoryDefaultTest.java         |   4 +-
 .../ProxyCreatorTestUsingCodegenPlugin.java        |   4 +-
 .../applib/CausewayModuleExtCommandLogApplib.java  |   4 +-
 .../commandlog/applib/dom/BackgroundService.java   | 189 +++-------
 .../BackgroundService_IntegTestAbstract.java       |  32 +-
 .../integtest/CommandLog_IntegTestAbstract.java    |   7 +-
 .../applib/integtest/model/CounterRepository.java  |   6 +-
 .../jdo/publishing/PublishingTestFactoryJdo.java   |  40 ++-
 .../jpa/publishing/PublishingTestFactoryJpa.java   |  47 +--
 .../integtests/WrapperFactory_async_IntegTest.java |  24 +-
 .../testdomain/interact/CommandArgumentTest.java   |  14 +-
 .../WrapperInteraction_Caching_IntegTest.java      |  20 +-
 30 files changed, 670 insertions(+), 1062 deletions(-)

diff --git a/api/applib/src/main/java/module-info.java 
b/api/applib/src/main/java/module-info.java
index c6f6b2ea3f3..838b56ff3c1 100644
--- a/api/applib/src/main/java/module-info.java
+++ b/api/applib/src/main/java/module-info.java
@@ -109,7 +109,6 @@
     exports org.apache.causeway.applib.services.userreg.events;
     exports org.apache.causeway.applib.services.userreg;
     exports org.apache.causeway.applib.services.userui;
-    exports org.apache.causeway.applib.services.wrapper.callable;
     exports org.apache.causeway.applib.services.wrapper.control;
     exports org.apache.causeway.applib.services.wrapper.events;
     exports org.apache.causeway.applib.services.wrapper.listeners;
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/WrapperFactory.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/WrapperFactory.java
index ef6403e74af..d64fd649f3d 100644
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/WrapperFactory.java
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/WrapperFactory.java
@@ -19,89 +19,85 @@
 package org.apache.causeway.applib.services.wrapper;
 
 import java.util.List;
-import java.util.concurrent.ExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Function;
 
 import org.apache.causeway.applib.exceptions.recoverable.InteractionException;
 import org.apache.causeway.applib.services.factory.FactoryService;
-import org.apache.causeway.applib.services.wrapper.callable.AsyncCallable;
 import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.wrapper.events.InteractionEvent;
 import 
org.apache.causeway.applib.services.wrapper.listeners.InteractionListener;
 
 /**
- *
  * Provides the ability to 'wrap' a domain object such that it can
  * be interacted with while enforcing the hide/disable/validate rules implied 
by
  * the Apache Causeway programming model.
  *
- * <p>
- * This capability goes beyond enforcing the (imperative) constraints within
+ * <p> This capability goes beyond enforcing the (imperative) constraints 
within
  * the `hideXxx()`, `disableXxx()` and `validateXxx()` supporting methods; it
  * also enforces (declarative) constraints such as those represented by
  * annotations, eg `@Parameter(maxLength=...)` or `@Property(mustSatisfy=...)`.
- * </p>
  *
- * <p>
- * The wrapper can alternatively also be used to execute the action
+ * <p> The wrapper can alternatively also be used to execute the action
  * asynchronously, through an {@link java.util.concurrent.ExecutorService}.
  * Any business rules will be invoked synchronously beforehand, however.
- * </p>
  *
- * <p>
- * The 'wrap' is a runtime-code-generated proxy that wraps the underlying 
domain
+ * <p> The 'wrap' is a runtime-code-generated proxy that wraps the underlying 
domain
  * object. The wrapper can then be interacted with as follows:
  * <ul>
- * <li>a <tt>get</tt> method for properties or collections</li>
- * <li>a <tt>set</tt> method for properties</li>
- * <li>any action</li>
+ *   <li>a <tt>get</tt> method for properties or collections</li>
+ *   <li>a <tt>set</tt> method for properties</li>
+ *   <li>any action</li>
  * </ul>
- * </p>
  *
- * <p>
- * Calling any of the above methods may result in a (subclass of)
+ * <p> Calling any of the above methods may result in a (subclass of)
  * {@link InteractionException} if the object disallows it. For example, if a
  * property is annotated as hidden then a {@link HiddenException} will
  * be thrown. Similarly if an action has a <tt>validate</tt> method and the
  * supplied arguments are invalid then a {@link InvalidException} will be
  * thrown.
- * </p>
  *
- * <p>
- * In addition, the following methods may also be called:
+ * <p> In addition, the following methods may also be called:
  * <ul>
- * <li>the <tt>title</tt> method</li>
- * <li>any <tt>defaultXxx</tt> or <tt>choicesXxx</tt> method</li>
+ *   <li>the <tt>title</tt> method</li>
+ *   <li>any <tt>defaultXxx</tt> or <tt>choicesXxx</tt> method</li>
  * </ul>
- * </p>
  *
- * <p>
- * If the object has (see {@link #isWrapper(Object)} already been wrapped),
+ * <p> If the object has (see {@link #isWrapper(Object)} already been wrapped),
  * then should just return the object back unchanged.
- * </p>
  *
- * @since 1.x {@index}
+ * @since 1.x revised for 3.4 {@index}
  */
 public interface WrapperFactory {
 
+    /**
+     * @since 3.4 {@index}
+     * @see CompletableFuture
+     */
+    interface AsyncProxy<T> {
+        AsyncProxy<Void> thenAcceptAsync(Consumer<? super T> action);
+        <U> AsyncProxy<U> thenApplyAsync(Function<? super T, ? extends U> fn);
+        AsyncProxy<T> orTimeout(long timeout, TimeUnit unit);
+        T join();
+    }
+
     /**
      * Provides the &quot;wrapper&quot; of a domain object against which to 
invoke the action.
      *
-     * <p>
-     *     The provided {@link SyncControl} determines whether business rules 
are checked first, and conversely
-     *     whether the action is executed.  There are therefore three typical 
cases:
-     *     <ul>
-     *         <li>check rules, execute action</li>
-     *         <li>skip rules, execute action</li>
-     *         <li>check rules, skip action</li>
-     *     </ul>
-     *     <p>
-     *         The last logical option (skip rules, skip action) is valid but 
doesn't make sense, as it's basically a no-op.
-     *     </p>
-     * </p>
+     * <p>The provided {@link SyncControl} determines whether business rules 
are checked first, and conversely
+     * whether the action is executed.  There are therefore three typical 
cases:
+     * <ul>
+     *   <li>check rules, execute action</li>
+     *   <li>skip rules, execute action</li>
+     *   <li>check rules, skip action</li>
+     * </ul>
      *
-     * <p>
-     * Otherwise, will do all the validations (raise exceptions as required
+     * <p>The last logical option (skip rules, skip action) is valid but 
doesn't make sense, as it's basically a no-op.
+     *
+     * <p>Otherwise, will do all the validations (raise exceptions as required
      * etc.), but doesn't modify the model.
      */
     <T> T wrap(T domainObject,
@@ -177,50 +173,32 @@ default <T extends Mixin<MIXEE>, MIXEE> T 
wrapMixinT(Class<T> mixinClass, MIXEE
     //
 
     /**
-     * Returns a proxy object for the provided {@code domainObject},
-     * through which can execute the action asynchronously (in another thread).
+     * Returns a {@link CompletableFuture} holding a proxy object for the 
provided {@code domainObject},
+     * through which one can execute the action asynchronously (in another 
thread).
      *
      * @param <T> - the type of the domain object
-     * @param <R> - the type of the return of the action
      * @param domainObject
      * @param asyncControl
      *
-     * @since 2.0
+     * @since 3.4
      */
-    <T,R> T asyncWrap(T domainObject,
-                      AsyncControl<R> asyncControl);
+    <T> AsyncProxy<T> asyncWrap(T domainObject, AsyncControl asyncControl);
 
     /**
-     * Returns a proxy object for the provided {@code mixinClass},
-     * through which can execute the action asynchronously (in another thread).
+     * Returns a {@link CompletableFuture} holding a proxy object for the 
provided {@code mixinClass},
+     * through which one can execute the action asynchronously (in another 
thread).
      *
-     * @param <T>
+     * @param <T> - the type of the mixin
      * @param mixinClass
      * @param mixee
      * @param asyncControl
      *
-     * @since 2.0
+     * @since 3.4
      */
-    <T,R> T asyncWrapMixin(
-                   Class<T> mixinClass, Object mixee,
-                   AsyncControl<R> asyncControl);
-
-    /**
-     * Returns a proxy object for the provided {@code mixinClass},
-     * through which can execute the action asynchronously (in another thread).
-     *
-     * @param <T>
-     * @param mixinClass
-     * @param mixee
-     * @param asyncControl
-     *
-     * @since 2.0
-     */
-    default <T extends MIXEE,MIXEE, R> T asyncWrapMixinT(
-                   Class<T> mixinClass, MIXEE mixee,
-                   AsyncControl<R> asyncControl) {
-        return asyncWrapMixin(mixinClass, mixee, asyncControl);
-    }
+    <T> AsyncProxy<T> asyncWrapMixin(
+                   Class<T> mixinClass,
+                   Object mixee,
+                   AsyncControl asyncControl);
 
     //
     // -- INTERACTION EVENT HANDLING
@@ -230,15 +208,13 @@ default <T extends MIXEE,MIXEE, R> T asyncWrapMixinT(
      * All {@link InteractionListener}s that have been registered using
      * {@link #addInteractionListener(InteractionListener)}.
      */
-    // ...
     List<InteractionListener> getListeners();
 
     /**
      * Registers an {@link InteractionListener}, to be notified of interactions
      * on all wrappers.
      *
-     * <p>
-     * This is retrospective: the listener will be notified of interactions 
even
+     * <p> This is retrospective: the listener will be notified of 
interactions even
      * on wrappers created before the listener was installed. (From an
      * implementation perspective this is because the wrappers delegate back to
      * the container to fire the events).
@@ -251,8 +227,7 @@ default <T extends MIXEE,MIXEE, R> T asyncWrapMixinT(
      * Remove an {@link InteractionListener}, to no longer be notified of
      * interactions on wrappers.
      *
-     * <p>
-     * This is retrospective: the listener will no longer be notified of any
+     * <p>This is retrospective: the listener will no longer be notified of any
      * interactions created on any wrappers, not just on those wrappers created
      * subsequently. (From an implementation perspective this is because the
      * wrappers delegate back to the container to fire the events).
@@ -263,15 +238,4 @@ boolean removeInteractionListener(
                     InteractionListener listener);
 
     void notifyListeners(InteractionEvent ev);
-
-    //
-    // -- SPI for ExecutorServices
-    //
-
-    /**
-     * Provides a mechanism for custom implementations of {@link 
java.util.concurrent.ExecutorService}, as installed
-     * using {@link AsyncControl#with(ExecutorService)}, to actually execute 
the {@link AsyncCallable} that they
-     * are passed initially during {@link WrapperFactory#asyncWrap(Object, 
AsyncControl)} and its brethren.
-     */
-    <R> R execute(AsyncCallable<R> asyncCallable);
 }
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/callable/AsyncCallable.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/callable/AsyncCallable.java
deleted file mode 100644
index a332fcebe01..00000000000
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/callable/AsyncCallable.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- *  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.applib.services.wrapper.callable;
-
-import java.io.Serializable;
-import java.util.UUID;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutorService;
-
-import org.springframework.transaction.annotation.Propagation;
-
-import org.apache.causeway.applib.services.command.Command;
-import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
-import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
-import org.apache.causeway.schema.cmd.v2.CommandDto;
-
-/**
- * Provides access to the details of the asynchronous callable (representing a 
child command to be executed
- * asynchronously) when using
- * {@link 
org.apache.causeway.applib.services.wrapper.WrapperFactory#asyncWrap(Object, 
AsyncControl)} and its brethren.
- *
- * <p>
- *     To explain in a little more depth; we can execute commands (actions 
etc) asynchronously using
- *     {@link 
org.apache.causeway.applib.services.wrapper.WrapperFactory#asyncWrap(Object, 
AsyncControl)} or similar.
- *     The {@link AsyncControl} parameter allows various aspects of this to be 
controlled, one such being the
- *     implementation of the {@link java.util.concurrent.ExecutorService} 
(using
- *     {@link AsyncControl#with(ExecutorService)}).
- * </p>
- *
- * <p>
- *     The default {@link ExecutorService} is just {@link 
java.util.concurrent.ForkJoinPool}, and this and similar
- *     implementations will hold the provided callable in memory and execute 
it in due course.  For these out-of-the-box
- *     implementations, the {@link java.util.concurrent.Callable} is a black 
box and they have no need to look inside
- *     it.  So long as the implementation of the Callable is not serialized 
then deserialized (ie is only ever held in
- *     memory), then all will work fine.
- * </p>
- *
- * <p>
- *     This interface, though, is intended to expose the details of the passed 
{@link java.util.concurrent.Callable},
- *     most notably the {@link CommandDto} to be executed.  The main use case 
this supports is to allow a custom
- *     implementation of {@link ExecutorService} to be provided that could do 
more sophisticated things, for example
- *     persisting the callable somewhere, either exploiting the fact that the 
object is serializable, or perhaps by
- *     unpacking the parts and persisting (for example, as a 
<code>CommandLogEntry</code> courtesy of the
- *     commandlog extension).
- * </p>
- *
- * <p>
- *     These custom implementations of {@link ExecutorService} must however 
reinitialize the state of the callable,
- *     either by injecting in services using {@link 
org.apache.causeway.applib.services.inject.ServiceInjector} and then
- *     just <code>call()</code>ing it, or alternatively and more 
straightforwardly simply executing it using
- *     {@link 
org.apache.causeway.applib.services.wrapper.WrapperFactory#execute(AsyncCallable)}.
- * </p>
- *
- * @since 2.0 {@index}
- */
-public interface AsyncCallable<R> extends Serializable, Callable<R> {
-
-    /**
-     * The requested {@link InteractionContext} to execute the command, as 
inferred from the {@link AsyncControl}
-     * that was used to call
-     * {@link 
org.apache.causeway.applib.services.wrapper.WrapperFactory#asyncWrap(Object, 
AsyncControl)} and its ilk.
-     */
-    InteractionContext getInteractionContext();
-
-    /**
-     * The transaction propagation to use when creating a new {@link 
org.apache.causeway.applib.services.iactn.Interaction}
-     * in which to execute the child command.
-     */
-    Propagation getPropagation();
-
-    /**
-     * Details of the actual child command (action or property edit) to be 
performed.
-     *
-     * <p>
-     *     (Ultimately this is handed onto the {@link 
org.apache.causeway.applib.services.command.CommandExecutorService}).
-     * </p>
-     */
-    CommandDto getCommandDto();
-
-    /**
-     * The type of the object returned by the child command once finally 
executed.
-     */
-    Class<R> getReturnType();
-
-    /**
-     * The unique {@link Command#getInteractionId() interactionId} of the 
parent {@link Command}, which is to say the
-     * {@link Command} that was active in the original interaction where
-     * {@link 
org.apache.causeway.applib.services.wrapper.WrapperFactory#asyncWrap(Object, 
AsyncControl)} (or its brethren)
-     * was called.
-     *
-     * <p>
-     *     This can be useful for custom implementations of {@link 
ExecutorService} that use the commandlog
-     *     extension's <code>CommandLogEntry</code>, to link parent and child 
commands together.
-     * </p>
-     */
-    UUID getParentInteractionId();
-
-}
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
index e59fdeef199..9086c945f2d 100644
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl.java
@@ -20,23 +20,18 @@
 
 import java.time.ZoneId;
 import java.util.Locale;
-import java.util.concurrent.CancellationException;
-import java.util.concurrent.ExecutionException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicReference;
 
 import org.jspecify.annotations.NonNull;
 import org.jspecify.annotations.Nullable;
 
 import org.apache.causeway.applib.clock.VirtualClock;
+import org.apache.causeway.applib.locale.UserLocale;
+import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
 import org.apache.causeway.applib.services.user.UserMemento;
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-import org.apache.causeway.commons.internal.assertions._Assert;
 
-import lombok.SneakyThrows;
 import lombok.extern.log4j.Log4j2;
 
 /**
@@ -55,11 +50,9 @@
  * @since 2.0 {@index}
  */
 @Log4j2
-public record AsyncControl<R>(
-        Class<R> returnType,
+public record AsyncControl (
         SyncControl syncControl,
         @Nullable ExecutorService executorService,
-
         /**
          * Defaults to the system clock, if not overridden
          */
@@ -78,67 +71,37 @@ public record AsyncControl<R>(
          *
          * <p>If not specified, then the user of the current foreground 
session is used.
          */
-        @Nullable UserMemento user,
-        /**
-         * Contains the result of the invocation.
-         *
-         * <p> If an entity is returned, then the object is automatically 
detached
-         * because the persistence session within which it was obtained will 
have
-         * been closed already.
-         */
-        AtomicReference<Future<R>> futureRef) {
-
-    /**
-     * Factory method to instantiate a control instance for a void action
-     * or a property edit (where there is no need or intention to provide a
-     * return value through the `Future`).
-     */
-    public static AsyncControl<Void> returningVoid() {
-        return new AsyncControl<>(Void.class);
-    }
-
-    /**
-     * Factory method to instantiate for a control instance for an action
-     * returning a value of `<R>` (where this value will be returned through
-     * the `Future`).
-     */
-    public static <X> AsyncControl<X> returning(final Class<X> cls) {
-        return new AsyncControl<X>(cls);
-    }
+        @Nullable UserMemento user
+        ) {
 
-    // non canonical constructor
-    private AsyncControl(final Class<R> returnType) {
-        this(returnType,
-            SyncControl.control(),
-            /*executorService*/null, /*clock*/null, /*locale*/null, 
/*timeZone*/null, /*user*/null,
-            new AtomicReference<>());
+    public static AsyncControl defaults() {
+        return new AsyncControl(SyncControl.defaults(),
+                /*executorService*/null,
+                /*clock*/null, /*locale*/null, /*timeZone*/null, /*user*/null);
     }
 
     /**
      * Explicitly set the action to be executed.
      */
-    public AsyncControl<R> withExecute() {
-        return new AsyncControl<>(returnType, syncControl.withExecute(), 
executorService, clock, locale, timeZone, user, futureRef);
+    public AsyncControl withExecute() {
+        return new AsyncControl(syncControl.withExecute(), executorService, 
clock, locale, timeZone, user);
     }
-
     /**
-     * Explicitly set the action to <i>not</i >be executed, in other words a
-     * &quot;dry run&quot;.
+     * Explicitly set the action to <i>not</i >be executed, in other words a 
'dry run'.
      */
-    public AsyncControl<R> withNoExecute() {
-        return new AsyncControl<>(returnType, syncControl.withExecute(), 
executorService, clock, locale, timeZone, user, futureRef);
+    public AsyncControl withNoExecute() {
+        return new AsyncControl(syncControl.withNoExecute(), executorService, 
clock, locale, timeZone, user);
     }
 
     /**
      * Skip checking business rules (hide/disable/validate) before
      * executing the underlying property or action
      */
-    public AsyncControl<R> withSkipRules() {
-        return new AsyncControl<>(returnType, syncControl.withSkipRules(), 
executorService, clock, locale, timeZone, user, futureRef);
+    public AsyncControl withSkipRules() {
+        return new AsyncControl(syncControl.withSkipRules(), executorService, 
clock, locale, timeZone, user);
     }
-
-    public AsyncControl<R> withCheckRules() {
-        return new AsyncControl<>(returnType, syncControl.withCheckRules(), 
executorService, clock, locale, timeZone, user, futureRef);
+    public AsyncControl withCheckRules() {
+        return new AsyncControl(syncControl.withCheckRules(), executorService, 
clock, locale, timeZone, user);
     }
 
     /**
@@ -148,7 +111,7 @@ public AsyncControl<R> withCheckRules() {
      *
      * <p>Changes are made in place, returning the same instance.
      */
-    public AsyncControl<R> setExceptionHandler(final @NonNull ExceptionHandler 
exceptionHandler) {
+    public AsyncControl setExceptionHandler(final @NonNull ExceptionHandler 
exceptionHandler) {
         syncControl.setExceptionHandler(exceptionHandler);
         return this;
     }
@@ -162,29 +125,33 @@ public AsyncControl<R> setExceptionHandler(final @NonNull 
ExceptionHandler excep
      *
      * @param executorService - null-able
      */
-    public AsyncControl<R> with(final ExecutorService executorService) {
-        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
+    public AsyncControl with(final ExecutorService executorService) {
+        return new AsyncControl(syncControl, executorService, clock, locale, 
timeZone, user);
+    }
+
+    public AsyncControl listen(final SyncControl.@NonNull CommandListener 
commandListener) {
+        return new AsyncControl(syncControl.listen(commandListener), 
executorService, clock, locale, timeZone, user);
     }
 
     /**
      * Defaults to the system clock, if not overridden
      */
-    public AsyncControl<R> withClock(final @NonNull VirtualClock clock) {
-        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
+    public AsyncControl withClock(final @NonNull VirtualClock clock) {
+        return new AsyncControl(syncControl, executorService, clock, locale, 
timeZone, user);
     }
 
     /**
      * Defaults to the system locale, if not overridden
      */
-    public AsyncControl<R> withLocale(final @NonNull Locale locale) {
-        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
+    public AsyncControl withLocale(final @NonNull Locale locale) {
+        return new AsyncControl(syncControl, executorService, clock, locale, 
timeZone, user);
     }
 
     /**
      * Defaults to the system time zone, if not overridden
      */
-    public AsyncControl<R> withTimeZone(final @NonNull ZoneId timeZone) {
-        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
+    public AsyncControl withTimeZone(final @NonNull ZoneId timeZone) {
+        return new AsyncControl(syncControl, executorService, clock, locale, 
timeZone, user);
     }
 
     /**
@@ -193,58 +160,18 @@ public AsyncControl<R> withTimeZone(final @NonNull ZoneId 
timeZone) {
      *
      * <p>If not specified, then the user of the current foreground session is 
used.
      */
-    public AsyncControl<R> withUser(final @NonNull UserMemento user) {
-        return new AsyncControl<>(returnType, syncControl, executorService, 
clock, locale, timeZone, user, futureRef);
-    }
-
-    public Future<R> future() {
-        return futureRef.get();
+    public AsyncControl withUser(final @NonNull UserMemento user) {
+        return new AsyncControl(syncControl, executorService, clock, locale, 
timeZone, user);
     }
 
-    /**
-     * Waits on the callers thread, for a maximum amount of time,
-     * for the result of the invocation to become available.
-     * @param timeout the maximum time to wait
-     * @param unit the time unit of the {@code timeout} argument
-     * @return the invocation result
-     * @throws CancellationException if the computation was cancelled
-     * @throws ExecutionException if the computation threw an exception
-     * @throws InterruptedException if the current thread was interrupted 
while waiting
-     * @throws TimeoutException if the wait timed out
-     */
-    @SuppressWarnings("javadoc")
-    @SneakyThrows
-    public R waitForResult(final long timeout, final TimeUnit unit) {
-        _Assert.assertNotNull(future(),
-                ()->"detected call to waitForResult(..) before future was 
set");
-        return future().get(timeout, unit);
+    public InteractionContext override(
+            final InteractionContext interactionContext) {
+        return InteractionContext.builder()
+            
.clock(Optional.ofNullable(clock()).orElseGet(interactionContext::getClock))
+            
.locale(Optional.ofNullable(locale()).map(UserLocale::valueOf).orElse(null)) // 
if not set in asyncControl use defaults (set override to null)
+            
.timeZone(Optional.ofNullable(timeZone()).orElseGet(interactionContext::getTimeZone))
+            
.user(Optional.ofNullable(user()).orElseGet(interactionContext::getUser))
+            .build();
     }
 
-    // -- DEPRECATIONS
-
-    @Deprecated public Class<R> getReturnType() { return returnType(); }
-    @Deprecated public ExecutorService getExecutorService() { return 
executorService(); }
-
-    /**
-     * Defaults to the system clock, if not overridden
-     */
-    @Deprecated public VirtualClock getClock() { return clock(); }
-    /**
-     * Defaults to the system locale, if not overridden
-     */
-    @Deprecated public Locale getLocale() { return locale(); }
-    /**
-     * Defaults to the system time zone, if not overridden
-     */
-    @Deprecated public ZoneId getTimeZone() { return timeZone(); }
-    /**
-     * Specifies the user for the session used to execute the command
-     * asynchronously, in the background.
-     *
-     * <p>If not specified, then the user of the current foreground session is 
used.
-     */
-    @Deprecated public UserMemento getUser() { return user(); }
-
-    @Deprecated public Future<R> getFuture() { return future(); }
-
 }
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
index 38d3f235a10..ac826d8a126 100644
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
+++ 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/control/SyncControl.java
@@ -18,10 +18,15 @@
  */
 package org.apache.causeway.applib.services.wrapper.control;
 
+import java.util.UUID;
 import java.util.concurrent.atomic.AtomicReference;
 
 import org.jspecify.annotations.Nullable;
 
+import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
+import org.apache.causeway.commons.collections.Can;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
 import lombok.NonNull;
 
 /**
@@ -30,63 +35,80 @@
  * @since 2.0 revised for 3.4 {@index}
  */
 public record SyncControl(
+        /**
+         * Skip checking business rules (hide/disable/validate) before
+         * executing the underlying property or action
+         */
+        boolean isSkipRules,
+        boolean isSkipExecute,
+        /**
+         * Get notified on action invocation or property change.
+         */
+        Can<CommandListener> commandListeners,
         /**
          * How to handle exceptions if they occur, using the provided
          * {@link ExceptionHandler}.
          *
          * <p>The default behaviour is to rethrow the exception.
          */
-        AtomicReference<ExceptionHandler> exceptionHandlerRef,
-        boolean isSkipExecute,
-        /**
-         * Skip checking business rules (hide/disable/validate) before
-         * executing the underlying property or action
-         */
-        boolean isSkipRules) {
+        AtomicReference<ExceptionHandler> exceptionHandlerRef) {
 
-    public static SyncControl control() {
-        return new SyncControl(null, false, false);
+    @FunctionalInterface
+    public interface CommandListener {
+        public void onCommand(
+                InteractionContext interactionContext,
+                CommandDto commandDto,
+                UUID parentInteractionId);
+    }
+
+    public static SyncControl defaults() {
+        return new SyncControl(false, false, null, null);
     }
 
     public SyncControl(
-            @Nullable AtomicReference<ExceptionHandler> exceptionHandlerRef,
+            boolean isSkipRules,
             boolean isSkipExecute,
-            boolean isSkipRules) {
+            @Nullable Can<CommandListener> commandListeners,
+            @Nullable AtomicReference<ExceptionHandler> exceptionHandlerRef) {
+        this.isSkipRules = isSkipRules;
+        this.isSkipExecute = isSkipExecute;
+        this.commandListeners = commandListeners!=null
+                ? commandListeners
+                : Can.empty();
         this.exceptionHandlerRef = exceptionHandlerRef!=null
                 ? exceptionHandlerRef
                 : new AtomicReference<>();
-        this.isSkipExecute = isSkipExecute;
-        this.isSkipRules = isSkipRules;
         if(this.exceptionHandlerRef.get()==null) {
             this.exceptionHandlerRef.set(exception -> { throw exception; });
         }
     }
 
     /**
-     * Explicitly set the action to be executed.
+     * Skip checking business rules (hide/disable/validate) before
+     * executing the underlying property or action
      */
-    public SyncControl withExecute() {
-        return new SyncControl(exceptionHandlerRef, false, isSkipRules);
+    public SyncControl withSkipRules() {
+        return new SyncControl(true, isSkipExecute, commandListeners, 
exceptionHandlerRef);
+    }
+    public SyncControl withCheckRules() {
+        return new SyncControl(false, isSkipExecute, commandListeners, 
exceptionHandlerRef);
     }
 
     /**
-     * Explicitly set the action to <i>not</i >be executed, in other words a
-     * &quot;dry run&quot;.
+     * Explicitly set the action to be executed.
      */
-    public SyncControl withNoExecute() {
-        return new SyncControl(exceptionHandlerRef, true, isSkipRules);
+    public SyncControl withExecute() {
+        return new SyncControl(isSkipRules, false, commandListeners, 
exceptionHandlerRef);
     }
-
     /**
-     * Skip checking business rules (hide/disable/validate) before
-     * executing the underlying property or action
+     * Explicitly set the action to <i>not</i> be executed, in other words a 
'dry run'.
      */
-    public SyncControl withSkipRules() {
-        return new SyncControl(exceptionHandlerRef, isSkipExecute, true);
+    public SyncControl withNoExecute() {
+        return new SyncControl(isSkipRules, true, commandListeners, 
exceptionHandlerRef);
     }
 
-    public SyncControl withCheckRules() {
-        return new SyncControl(exceptionHandlerRef, isSkipExecute, false);
+    public SyncControl listen(@NonNull CommandListener commandListener) {
+        return new SyncControl(isSkipRules, isSkipExecute, 
commandListeners.add(commandListener), exceptionHandlerRef);
     }
 
     /**
diff --git 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/events/ParseValueEvent.java
 
b/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/events/ParseValueEvent.java
deleted file mode 100644
index 25b6007432b..00000000000
--- 
a/api/applib/src/main/java/org/apache/causeway/applib/services/wrapper/events/ParseValueEvent.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- *  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.applib.services.wrapper.events;
-
-import org.apache.causeway.applib.Identifier;
-import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-
-/**
- * Supported only by {@link WrapperFactory},
- * represents a check as to whether the proposed values of the value type is 
valid.
- * <p>
- * If {@link #getReason()} is not <tt>null</tt> then provides the reason why 
the
- * proposed value is invalid, otherwise the new value is acceptable.
- *
- * @since 1.x {@index}
- */
-@Deprecated // not used
-public class ParseValueEvent extends ValidityEvent {
-
-    private static Object coalesce(final Object source, final String proposed) 
{
-        return source != null ? source : proposed;
-    }
-
-    private final String proposed;
-
-    public ParseValueEvent(final Object source, final Identifier 
classIdentifier, final String proposed) {
-        super(coalesce(source, proposed), classIdentifier);
-        this.proposed = proposed;
-    }
-
-    /**
-     * Will be the source provided in the
-     * {@link #ParseValueEvent(Object, Identifier, String) constructor} if not
-     * null, otherwise will fallback to the proposed value.
-     */
-    @Override
-    public Object getSource() {
-        return super.getSource();
-    }
-
-    @Override
-    public String getProposed() {
-        return proposed;
-    }
-
-}
diff --git 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
index 6b6d5635864..a5f9a75b633 100644
--- 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
+++ 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/AsyncControl_Test.java
@@ -34,7 +34,7 @@ class AsyncControl_Test {
     public void defaults() throws Exception {
 
         // given
-        var control = AsyncControl.returningVoid();
+        var control = AsyncControl.defaults();
 
         // then
         
Assertions.assertThat(control.syncControl().isSkipExecute()).isEqualTo(false);
@@ -44,7 +44,7 @@ public void defaults() throws Exception {
     @Test
     public void check_rules() throws Exception {
         // given
-        var control = AsyncControl.returningVoid();
+        var control = AsyncControl.defaults();
 
         // when
         control = control.withCheckRules();
@@ -58,7 +58,7 @@ public void check_rules() throws Exception {
     public void skip_rules() throws Exception {
 
         // given
-        var control = AsyncControl.returningVoid();
+        var control = AsyncControl.defaults();
 
         // when
         control = control.withSkipRules();
@@ -72,7 +72,7 @@ public void skip_rules() throws Exception {
     public void user() throws Exception {
 
         // given
-        var control = AsyncControl.returningVoid();
+        var control = AsyncControl.defaults();
 
         // when
         control = control.withUser(UserMemento.ofName("fred"));
@@ -85,7 +85,7 @@ public void user() throws Exception {
     public void roles() throws Exception {
 
         // given
-        var control = AsyncControl.returningVoid();
+        var control = AsyncControl.defaults();
 
         // when
         control = control.withUser(UserMemento.ofNameAndRoleNames("fred", 
"role-1", "role-2"));
@@ -101,7 +101,7 @@ public void chaining() throws Exception {
         var executorService = new ExecutorServiceAdapter(new 
TaskExecutorAdapter(command -> {}));
         var exceptionHandler = (ExceptionHandler) ex -> null;
 
-        var control = AsyncControl.returning(String.class)
+        var control = AsyncControl.defaults()
                 .withSkipRules()
                 .withUser(UserMemento.ofNameAndRoleNames("fred", "role-1", 
"role-2"))
                 .with(executorService)
diff --git 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
index fc98b02e18f..051b7727d02 100644
--- 
a/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
+++ 
b/api/applib/src/test/java/org/apache/causeway/applib/services/wrapper/control/SyncControl_Test.java
@@ -29,7 +29,7 @@ class SyncControl_Test {
     public void defaults() throws Exception {
 
         // given
-        var control = SyncControl.control();
+        var control = SyncControl.defaults();
 
         // then
         assertFalse(control.isSkipExecute());
@@ -39,7 +39,7 @@ public void defaults() throws Exception {
     @Test
     public void check_rules() throws Exception {
         // given
-        var control = SyncControl.control();
+        var control = SyncControl.defaults();
 
         // when
         control = control.withCheckRules();
@@ -53,7 +53,7 @@ public void check_rules() throws Exception {
     public void skip_rules() throws Exception {
 
         // given
-        var control = SyncControl.control();
+        var control = SyncControl.defaults();
 
         // when
         control = control.withSkipRules();
@@ -67,7 +67,7 @@ public void skip_rules() throws Exception {
     public void execute() throws Exception {
 
         // given
-        var control = SyncControl.control();
+        var control = SyncControl.defaults();
 
         // when
         control = control.withExecute();
@@ -81,7 +81,7 @@ public void execute() throws Exception {
     public void no_execute() throws Exception {
 
         // given
-        var control = SyncControl.control();
+        var control = SyncControl.defaults();
 
         // when
         control = control.withNoExecute();
@@ -96,7 +96,7 @@ public void chaining() throws Exception {
 
         ExceptionHandler exceptionHandler = ex -> null;
 
-        var control = SyncControl.control()
+        var control = SyncControl.defaults()
                 .withNoExecute()
                 .withSkipRules()
                 .setExceptionHandler(exceptionHandler);
diff --git 
a/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/_testing/WrapperFactory_forTesting.java
 
b/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/_testing/WrapperFactory_forTesting.java
index 83f56c2862b..cd5642d074f 100644
--- 
a/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/_testing/WrapperFactory_forTesting.java
+++ 
b/core/metamodel/src/main/java/org/apache/causeway/core/metamodel/_testing/WrapperFactory_forTesting.java
@@ -21,7 +21,6 @@
 import java.util.List;
 
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-import org.apache.causeway.applib.services.wrapper.callable.AsyncCallable;
 import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.wrapper.events.InteractionEvent;
@@ -61,12 +60,12 @@ public <T> boolean isWrapper(T possibleWrappedDomainObject) 
{
     }
 
     @Override
-    public <T, R> T asyncWrap(T domainObject, AsyncControl<R> asyncControl) {
+    public <T> AsyncProxy<T> asyncWrap(T domainObject, AsyncControl 
asyncControl) {
         return null;
     }
 
     @Override
-    public <T, R> T asyncWrapMixin(Class<T> mixinClass, Object mixedIn, 
AsyncControl<R> asyncControl) {
+    public <T> AsyncProxy<T> asyncWrapMixin(Class<T> mixinClass, Object mixee, 
AsyncControl asyncControl) {
         return null;
     }
 
@@ -89,9 +88,4 @@ public boolean removeInteractionListener(InteractionListener 
listener) {
     public void notifyListeners(InteractionEvent ev) {
     }
 
-    @Override
-    public <R> R execute(AsyncCallable<R> asyncCallable) {
-        return null;
-    }
-
 }
diff --git 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
index 90fd46f407a..63c08567b29 100644
--- 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
+++ 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrapperInvocationHandler.java
@@ -24,6 +24,7 @@
 import org.jspecify.annotations.NonNull;
 import org.jspecify.annotations.Nullable;
 
+import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.commons.internal._Constants;
 import org.apache.causeway.commons.internal.base._Lazy;
 import org.apache.causeway.commons.internal.proxy.CachableInvocationHandler;
@@ -117,12 +118,8 @@ static WrapperInvocation of(Object target, Method method, 
Object[] args) {
             return new WrapperInvocation(origin, method, args!=null ? args : 
_Constants.emptyObjects);
         }
 
-        public boolean shouldEnforceRules() {
-            return !origin().syncControl().isSkipRules();
-        }
-
-        public boolean shouldExecute() {
-            return !origin().syncControl().isSkipExecute();
+        public SyncControl syncControl() {
+            return origin().syncControl();
         }
     }
 
diff --git 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrappingObject.java
 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrappingObject.java
index dec7ce86e0b..aa251e4d809 100644
--- 
a/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrappingObject.java
+++ 
b/core/runtime/src/main/java/org/apache/causeway/core/runtime/wrap/WrappingObject.java
@@ -63,13 +63,13 @@ record Origin(
          * fallback, used for non-proxied target, with no execute (no verify 
no rule checking).
          */
         public static Origin fallback(Object target) {
-            return new Origin(target, null, 
SyncControl.control().withNoExecute(), true);
+            return new Origin(target, null, 
SyncControl.defaults().withNoExecute(), true);
         }
         /**
          * fallback, used for non-proxied target as mixin, with no execute (no 
verify no rule checking)
          */
         public static Origin fallbackMixin(Object target, ManagedObject 
managedMixee) {
-            return new Origin(target, managedMixee, 
SyncControl.control().withNoExecute(), true);
+            return new Origin(target, managedMixee, 
SyncControl.defaults().withNoExecute(), true);
         }
         public Origin(Object pojo, SyncControl syncControl) {
             this(pojo, null, syncControl, false);
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncExecutorService.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncExecutorService.java
new file mode 100644
index 00000000000..886e61edafe
--- /dev/null
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncExecutorService.java
@@ -0,0 +1,134 @@
+/*
+ *  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.core.runtimeservices.wrapper;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.springframework.transaction.annotation.Propagation;
+
+import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
+import org.apache.causeway.applib.services.iactnlayer.InteractionService;
+import org.apache.causeway.applib.services.xactn.TransactionService;
+import org.apache.causeway.commons.functional.ThrowingRunnable;
+import org.apache.causeway.commons.internal.exceptions._Exceptions;
+
+record AsyncExecutorService(
+        InteractionService interactionService,
+        TransactionService transactionService,
+        InteractionContext interactionContext,
+        /**
+         * If empty then executes non-transactionally, similar to {@link 
Propagation#NEVER},
+         * but does NOT throw any exceptions if a transaction exists.
+         */
+        Optional<Propagation> propagation,
+        ExecutorService delegate) implements ExecutorService {
+
+    @Override
+    public <T> Future<T> submit(final Callable<T> task) {
+        return delegate.submit(()->call(task));
+    }
+
+    @Override
+    public <T> Future<T> submit(final Runnable task, final T result) {
+        return delegate.submit(()->run(task::run), result);
+    }
+
+    @Override
+    public Future<?> submit(final Runnable task) {
+        return delegate.submit(()->run(task::run));
+    }
+
+    @Override
+    public void execute(final Runnable command) {
+        delegate.execute(()->run(command::run));
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(final Collection<? extends 
Callable<T>> tasks) throws InterruptedException {
+        throw _Exceptions.unsupportedOperation(); //return 
delegate.invokeAll(tasks);
+    }
+
+    @Override
+    public <T> List<Future<T>> invokeAll(final Collection<? extends 
Callable<T>> tasks, final long timeout, final TimeUnit unit) throws 
InterruptedException {
+        throw _Exceptions.unsupportedOperation(); //return 
delegate.invokeAll(tasks, timeout, unit);
+    }
+
+    @Override
+    public <T> T invokeAny(final Collection<? extends Callable<T>> tasks) 
throws InterruptedException, ExecutionException {
+        throw _Exceptions.unsupportedOperation(); //return 
delegate.invokeAny(tasks);
+    }
+
+    @Override
+    public <T> T invokeAny(final Collection<? extends Callable<T>> tasks, 
final long timeout, final TimeUnit unit) throws InterruptedException, 
ExecutionException, TimeoutException {
+        throw _Exceptions.unsupportedOperation(); //return 
delegate.invokeAny(tasks, timeout, unit);
+    }
+
+    @Override
+    public void shutdown() {
+        delegate.shutdown();
+    }
+
+    @Override
+    public List<Runnable> shutdownNow() {
+        return delegate.shutdownNow();
+    }
+
+    @Override
+    public boolean awaitTermination(final long timeout, final TimeUnit unit) 
throws InterruptedException {
+        return delegate.awaitTermination(timeout, unit);
+    }
+
+    @Override
+    public boolean isShutdown() {
+        return delegate.isShutdown();
+    }
+
+    @Override
+    public boolean isTerminated() {
+        return delegate.isTerminated();
+    }
+
+    // -- HELPER
+
+    private void run(ThrowingRunnable runnable) {
+        if(propagation.isEmpty())
+            interactionService.run(interactionContext, runnable);
+        else
+            interactionService.run(interactionContext, ()->transactionService
+                .runTransactional(propagation().get(), runnable)
+                .ifFailureFail());
+    }
+
+    private <T> T call(Callable<T> callable) {
+        return propagation.isEmpty()
+            ? interactionService.call(interactionContext, callable)
+            : interactionService.call(interactionContext, 
()->transactionService
+                .callTransactional(propagation().get(), callable)
+                .valueAsNullableElseFail());
+    }
+
+}
\ No newline at end of file
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncProxyInternal.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncProxyInternal.java
new file mode 100644
index 00000000000..a2f3a8a67aa
--- /dev/null
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/AsyncProxyInternal.java
@@ -0,0 +1,51 @@
+/*
+ *  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.core.runtimeservices.wrapper;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.apache.causeway.applib.services.wrapper.WrapperFactory.AsyncProxy;
+
+//TODO this is just a proof of concept; chaining makes non sense once future 
is no longer a proxy
+record AsyncProxyInternal<T>(CompletableFuture<T> future, AsyncExecutorService 
executor) implements AsyncProxy<T> {
+    @Override public AsyncProxy<Void> thenAcceptAsync(Consumer<? super T> 
action) {
+        return map(in->in.thenAcceptAsync(action, executor));
+    }
+
+    @Override public <U> AsyncProxy<U> thenApplyAsync(Function<? super T, ? 
extends U> fn) {
+        return map(in->in.thenApplyAsync(fn, executor));
+    }
+
+    @Override public AsyncProxy<T> orTimeout(long timeout, TimeUnit unit) {
+        return map(in->in.orTimeout(timeout, unit));
+    }
+
+    @Override public T join() {
+        return future.join();
+    }
+
+    // -- HELPER
+
+    private <U> AsyncProxy<U> map(Function<CompletableFuture<T>, 
CompletableFuture<U>> fn) {
+        return new AsyncProxyInternal<>(fn.apply(future), executor);
+    }
+}
\ No newline at end of file
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
index b155151ec7c..359e28aeba5 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefault.java
@@ -18,15 +18,13 @@
  */
 package org.apache.causeway.core.runtimeservices.wrapper;
 
-import java.lang.reflect.InvocationHandler;
-import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.function.BiConsumer;
@@ -46,21 +44,11 @@
 import org.springframework.transaction.annotation.Propagation;
 
 import org.apache.causeway.applib.annotation.PriorityPrecedence;
-import org.apache.causeway.applib.locale.UserLocale;
-import org.apache.causeway.applib.services.bookmark.Bookmark;
-import org.apache.causeway.applib.services.bookmark.BookmarkService;
-import org.apache.causeway.applib.services.command.CommandExecutorService;
 import org.apache.causeway.applib.services.factory.FactoryService;
-import org.apache.causeway.applib.services.iactn.InteractionProvider;
 import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
-import org.apache.causeway.applib.services.iactnlayer.InteractionLayer;
 import org.apache.causeway.applib.services.iactnlayer.InteractionService;
-import org.apache.causeway.applib.services.inject.ServiceInjector;
-import org.apache.causeway.applib.services.repository.RepositoryService;
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-import org.apache.causeway.applib.services.wrapper.callable.AsyncCallable;
 import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
-import org.apache.causeway.applib.services.wrapper.control.AsyncLogger;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.wrapper.events.ActionArgumentEvent;
 import 
org.apache.causeway.applib.services.wrapper.events.ActionInvocationEvent;
@@ -80,40 +68,24 @@
 import 
org.apache.causeway.applib.services.wrapper.listeners.InteractionListener;
 import org.apache.causeway.applib.services.xactn.TransactionService;
 import org.apache.causeway.commons.internal.base._Casts;
-import org.apache.causeway.commons.internal.collections._Lists;
 import org.apache.causeway.commons.internal.exceptions._Exceptions;
 import org.apache.causeway.commons.internal.proxy.ProxyFactoryService;
-import org.apache.causeway.commons.internal.reflection._GenericResolver;
-import 
org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
-import 
org.apache.causeway.core.config.progmodel.ProgrammingModelConstants.MixinConstructor;
 import org.apache.causeway.core.metamodel.context.HasMetaModelContext;
 import org.apache.causeway.core.metamodel.context.MetaModelContext;
-import org.apache.causeway.core.metamodel.interactions.InteractionHead;
 import org.apache.causeway.core.metamodel.object.ManagedObject;
 import org.apache.causeway.core.metamodel.object.ManagedObjects;
 import org.apache.causeway.core.metamodel.services.command.CommandDtoFactory;
-import org.apache.causeway.core.metamodel.spec.feature.MixedIn;
-import org.apache.causeway.core.metamodel.spec.feature.MixedInMember;
-import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
-import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
-import 
org.apache.causeway.core.runtime.wrap.WrapperInvocationHandler.WrapperInvocation;
 import org.apache.causeway.core.runtime.wrap.WrappingObject;
 import 
org.apache.causeway.core.runtimeservices.CausewayModuleCoreRuntimeServices;
 import org.apache.causeway.core.runtimeservices.session.InteractionIdGenerator;
 import 
org.apache.causeway.core.runtimeservices.wrapper.dispatchers.InteractionEventDispatcher;
 import 
org.apache.causeway.core.runtimeservices.wrapper.dispatchers.InteractionEventDispatcherTypeSafe;
 import 
org.apache.causeway.core.runtimeservices.wrapper.handlers.ProxyGenerator;
-import org.apache.causeway.schema.cmd.v2.CommandDto;
-
-import static 
org.apache.causeway.applib.services.wrapper.control.SyncControl.control;
 
 import lombok.Getter;
-import lombok.RequiredArgsConstructor;
 
 /**
  * Default implementation of {@link WrapperFactory}.
- *
- * @since 2.0 {@index}
  */
 @Service
 @Named(WrapperFactoryDefault.LOGICAL_TYPE_NAME)
@@ -131,10 +103,6 @@ public class WrapperFactoryDefault
 
     @Inject private Provider<InteractionService> interactionServiceProvider;
     @Inject private Provider<TransactionService> transactionServiceProvider;
-    @Inject private Provider<CommandExecutorService> 
commandExecutorServiceProvider;
-    @Inject private Provider<InteractionProvider> interactionProviderProvider;
-    @Inject private Provider<BookmarkService> bookmarkServiceProvider;
-    @Inject private Provider<RepositoryService> repositoryServiceProvider;
     @Inject private InteractionIdGenerator interactionIdGenerator;
 
     private final List<InteractionListener> listeners = new ArrayList<>();
@@ -149,7 +117,7 @@ public void init() {
 
         this.commonExecutorService = newCommonExecutorService();
 
-        this.proxyGenerator = new ProxyGenerator(proxyFactoryService);
+        this.proxyGenerator = new ProxyGenerator(proxyFactoryService, 
interactionIdGenerator);
 
         putDispatcher(ObjectTitleEvent.class, 
InteractionListener::objectTitleRead);
         putDispatcher(PropertyVisibilityEvent.class, 
InteractionListener::propertyVisible);
@@ -180,7 +148,7 @@ public void close() {
     @Override
     public <T> T wrap(
             final @NonNull T domainObject) {
-        return wrap(domainObject, control());
+        return wrap(domainObject, SyncControl.defaults());
     }
 
     @Override
@@ -210,7 +178,7 @@ public <T> T wrap(
     public <T> T wrapMixin(
             final @NonNull Class<T> mixinClass,
             final @NonNull Object mixee) {
-        return wrapMixin(mixinClass, mixee, control());
+        return wrapMixin(mixinClass, mixee, SyncControl.defaults());
     }
 
     @Override
@@ -267,242 +235,33 @@ public <T> T unwrap(final T possibleWrappedDomainObject) 
{
 
     // -- ASYNC WRAPPING
 
-    @Override
-    public <T, R> T asyncWrap(
-            final @NonNull T domainObject,
-            final AsyncControl<R> asyncControl) {
-
-        var targetAdapter = 
adaptAndGuardAgainstWrappingNotSupported(domainObject);
-        if(targetAdapter.objSpec().isMixin()) {
-            throw _Exceptions.illegalArgument("cannot wrap a mixin instance 
directly, "
-                    + "use WrapperFactory.asyncWrapMixin(...) instead");
-        }
-
-        var handler = (InvocationHandler) (proxy, method, args) -> {
-            var resolvedMethod = _GenericResolver.resolveMethod(method, 
domainObject.getClass())
-                    .orElseThrow(); // fail early on attempt to invoke method 
that is not part of the meta-model
-
-            if (isInheritedFromJavaLangObject(method)) {
-                return method.invoke(domainObject, args);
-            }
-
-            if (!asyncControl.syncControl().isSkipRules()) {
-                var doih = proxyGenerator.handler(targetAdapter.objSpec());
-                var origin = WrappingObject.Origin.fallback(domainObject);
-                doih.invoke(new WrapperInvocation(origin, method, args));
-            }
-
-            var memberAndTarget = MemberAndTarget.forRegular(resolvedMethod, 
targetAdapter);
-            if(!memberAndTarget.isMemberFound()) {
-                return method.invoke(domainObject, args);
-            }
-
-            submitAsync(memberAndTarget, args, asyncControl);
-            return null;
-        };
+    AsyncExecutorService asyncExecutorService(AsyncControl asyncControl) {
+        return new AsyncExecutorService(
+                interactionServiceProvider.get(),
+                transactionServiceProvider.get(),
+                asyncControl.override(InteractionContext.builder().build()),
+                Optional.of(Propagation.REQUIRES_NEW),
+                Optional.ofNullable(asyncControl.executorService())
+                    .orElse(commonExecutorService));
+    }
 
-        @SuppressWarnings("unchecked")
-        var proxyClass = proxyFactoryService
-                .proxyClass(handler,
-                        (Class<T>)domainObject.getClass(), 
WrappingObject.class, WrappingObject.ADDITIONAL_FIELDS);
-        var proxyFactory = proxyFactoryService.factory(proxyClass);
-        return proxyFactory.createInstance(false);
+    @Override
+    public <T> AsyncProxy<T> asyncWrap(T domainObject, AsyncControl 
asyncControl) {
+        var proxy = wrap(domainObject, asyncControl.syncControl());
+        return new AsyncProxyInternal<>(
+                CompletableFuture.completedFuture(proxy),
+                asyncExecutorService(asyncControl));
     }
 
     @Override
-    public <T, R> T asyncWrapMixin(
+    public <T> AsyncProxy<T> asyncWrapMixin(
             final @NonNull Class<T> mixinClass,
             final @NonNull Object mixee,
-            final @NonNull AsyncControl<R> asyncControl) {
-
-        T mixin = factoryService.mixin(mixinClass, mixee);
-
-        var managedMixee = adaptAndGuardAgainstWrappingNotSupported(mixee);
-        var managedMixin = adaptAndGuardAgainstWrappingNotSupported(mixin);
-
-        var mixinConstructor = 
MixinConstructor.PUBLIC_SINGLE_ARG_RECEIVING_MIXEE
-                .getConstructorElseFail(mixinClass, mixee.getClass());
-
-        var handler = (InvocationHandler) (proxy, method, args) -> {
-            var resolvedMethod = _GenericResolver.resolveMethod(method, 
mixinClass)
-                    .orElseThrow(); // fail early on attempt to invoke method 
that is not part of the meta-model
-
-            if (isInheritedFromJavaLangObject(method)) {
-                return method.invoke(mixin, args);
-            }
-
-            if (!asyncControl.syncControl().isSkipRules()) {
-                var doih = proxyGenerator.handler(managedMixin.objSpec());
-                var origin = WrappingObject.Origin.fallbackMixin(mixin, 
managedMixee);
-                doih.invoke(new WrapperInvocation(origin, method, args));
-            }
-
-            var actionAndTarget = MemberAndTarget.forMixin(resolvedMethod, 
mixee, managedMixin);
-            if (!actionAndTarget.isMemberFound()) {
-                return method.invoke(mixin, args);
-            }
-
-            submitAsync(actionAndTarget, args, asyncControl);
-            return null;
-        };
-
-        var proxyClass = proxyFactoryService
-            .proxyClass(handler, mixinClass, new 
Class[]{WrappingObject.class}, WrappingObject.ADDITIONAL_FIELDS);
-
-        var proxyFactory = proxyFactoryService
-            .factory(proxyClass, mixinConstructor.getParameterTypes());
-
-        return proxyFactory.createInstance(new Object[]{ mixee });
-    }
-
-    private boolean isInheritedFromJavaLangObject(final Method method) {
-        return method.getDeclaringClass().equals(Object.class);
-    }
-
-    private <R> void submitAsync(
-            final MemberAndTarget memberAndTarget,
-            final Object[] args,
-            final AsyncControl<R> asyncControl) {
-
-        var interactionLayer = currentInteractionLayer();
-        var interactionContext = interactionLayer.interactionContext();
-        var asyncInteractionContext = interactionContextFrom(asyncControl, 
interactionContext);
-
-        var parentCommand = 
getInteractionService().currentInteractionElseFail().getCommand();
-        var parentInteractionId = parentCommand.getInteractionId();
-
-        var targetAdapter = memberAndTarget.target();
-        var method = memberAndTarget.method();
-
-        var head = InteractionHead.regular(targetAdapter);
-
-        var childInteractionId = interactionIdGenerator.interactionId();
-        CommandDto childCommandDto;
-        switch (memberAndTarget.type()) {
-            case ACTION:
-                var action = memberAndTarget.action();
-                var argAdapters = 
ManagedObject.adaptParameters(action.getParameters(), _Lists.ofArray(args));
-                childCommandDto = commandDtoFactory
-                        .asCommandDto(childInteractionId, head, action, 
argAdapters);
-                break;
-            case PROPERTY:
-                var property = memberAndTarget.property();
-                var propertyValueAdapter = 
ManagedObject.adaptProperty(property, args[0]);
-                childCommandDto = commandDtoFactory
-                        .asCommandDto(childInteractionId, head, property, 
propertyValueAdapter);
-                break;
-            default:
-                // shouldn't happen, already catered for this case previously
-                return;
-        }
-        var oidDto = childCommandDto.getTargets().getOid().get(0);
-
-        var rootExceptionHandler = 
asyncControl.syncControl().exceptionHandler();
-        asyncControl.setExceptionHandler(new AsyncLogger(rootExceptionHandler, 
method, Bookmark.forOidDto(oidDto)));
-
-        var executorService = 
Optional.ofNullable(asyncControl.executorService())
-                .orElse(commonExecutorService);
-        var asyncTask = getServiceInjector().injectServicesInto(new 
AsyncTask<R>(
-            asyncInteractionContext,
-            Propagation.REQUIRES_NEW,
-            childCommandDto,
-            asyncControl.returnType(),
-            parentInteractionId)); // this command becomes the parent of child 
command
-
-        var future = executorService.submit(asyncTask);
-        asyncControl.futureRef().set(future);
-    }
-
-    private static <R> InteractionContext interactionContextFrom(
-            final AsyncControl<R> asyncControl,
-            final InteractionContext interactionContext) {
-
-        return InteractionContext.builder()
-            
.clock(Optional.ofNullable(asyncControl.clock()).orElseGet(interactionContext::getClock))
-            
.locale(Optional.ofNullable(asyncControl.locale()).map(UserLocale::valueOf).orElse(null))
 // if not set in asyncControl use defaults (set override to null)
-            
.timeZone(Optional.ofNullable(asyncControl.timeZone()).orElseGet(interactionContext::getTimeZone))
-            
.user(Optional.ofNullable(asyncControl.user()).orElseGet(interactionContext::getUser))
-            .build();
-    }
-
-    record MemberAndTarget(
-            Type type,
-            /**
-             * Populated if and only if {@link #type} is {@link Type#ACTION}.
-             */
-            ObjectAction action,
-            /**
-             * Populated if and only if {@link #type} is {@link Type#PROPERTY}.
-             */
-            OneToOneAssociation property,
-            ManagedObject target,
-            Method method) {
-        static MemberAndTarget notFound() {
-            return new MemberAndTarget(Type.NONE, null, null, null, null);
-        }
-        static MemberAndTarget foundAction(final ObjectAction action, final 
ManagedObject target, final Method method) {
-            return new MemberAndTarget(Type.ACTION, action, null, target, 
method);
-        }
-        static MemberAndTarget foundProperty(final OneToOneAssociation 
property, final ManagedObject target, final Method method) {
-            return new MemberAndTarget(Type.PROPERTY, null, property, target, 
method);
-        }
-        static MemberAndTarget forRegular(
-                final ResolvedMethod method,
-                final ManagedObject targetAdapter) {
-
-            var objectMember = 
targetAdapter.objSpec().getMember(method).orElse(null);
-            if(objectMember == null) {
-                return MemberAndTarget.notFound();
-            }
-            if (objectMember instanceof OneToOneAssociation) {
-                return MemberAndTarget.foundProperty((OneToOneAssociation) 
objectMember, targetAdapter, method.method());
-            }
-            if (objectMember instanceof ObjectAction) {
-                return MemberAndTarget.foundAction((ObjectAction) 
objectMember, targetAdapter, method.method());
-            }
-
-            throw new UnsupportedOperationException(
-                    "Only properties and actions can be executed in the 
background "
-                            + "(method " + method.name() + " represents a " + 
objectMember.getFeatureType().name() + "')");
-        }
-        static <T> MemberAndTarget forMixin(
-                final ResolvedMethod method,
-                final T mixee,
-                final ManagedObject mixinAdapter) {
-
-            var mixinMember = 
mixinAdapter.objSpec().getMember(method).orElse(null);
-            if (mixinMember == null) {
-                return MemberAndTarget.notFound();
-            }
-
-            var mmc = mixinAdapter.getMetaModelContext();
-
-            // find corresponding action of the mixee (this is the 'real' 
target, the target usable for invocation).
-            var mixeeClass = mixee.getClass();
-
-            // don't care about anything other than actions
-            // (contributed properties and collections are read-only).
-            final ObjectAction targetAction = 
mmc.getSpecificationLoader().specForType(mixeeClass)
-            .flatMap(mixeeSpec->mixeeSpec.streamAnyActions(MixedIn.ONLY)
-                    .filter(act -> 
((MixedInMember)act).hasMixinAction((ObjectAction) mixinMember))
-                    .findFirst()
-            )
-            .orElseThrow(()->new UnsupportedOperationException(String.format(
-                    "Could not locate objectAction delegating to mixinAction 
id='%s' on mixee class '%s'",
-                    mixinMember.getId(), mixeeClass.getName())));
-
-            return MemberAndTarget.foundAction(targetAction, 
mmc.getObjectManager().adapt(mixee), method.method());
-        }
-
-        public boolean isMemberFound() {
-            return type != Type.NONE;
-        }
-
-        enum Type {
-            ACTION,
-            PROPERTY,
-            NONE
-        }
+            final @NonNull AsyncControl asyncControl) {
+        var proxy = wrapMixin(mixinClass, mixee, asyncControl.syncControl());
+        return new AsyncProxyInternal<>(
+                CompletableFuture.completedFuture(proxy),
+                asyncExecutorService(asyncControl));
     }
 
     // -- LISTENERS
@@ -565,102 +324,6 @@ public void dispatchTypeSafe(final T interactionEvent) {
         dispatchersByEventClass.put(type, dispatcher);
     }
 
-    private InteractionLayer currentInteractionLayer() {
-        return getInteractionService().currentInteractionLayerElseFail();
-    }
-
-    @RequiredArgsConstructor
-    private static class AsyncTask<R> implements AsyncCallable<R> {
-
-        private static final long serialVersionUID = 1L;
-
-        @Getter private final InteractionContext interactionContext;
-        @Getter private final Propagation propagation;
-        @Getter private final CommandDto commandDto;
-        @Getter private final Class<R> returnType;
-        @Getter private final UUID parentInteractionId;
-
-        /**
-         * Note this is a <code>transient</code> field, in order that
-         * {@link 
org.apache.causeway.applib.services.wrapper.callable.AsyncCallable} can be 
declared as
-         * {@link java.io.Serializable}.
-         *
-         * <p>
-         *  Because this field needs to be populated, the {@link 
java.util.concurrent.ExecutorService} that ultimately
-         *  executes the task will need to be a custom implementation because 
it must reinitialize this field first,
-         *  using the {@link ServiceInjector} service.  Alternatively, it 
could call
-         *  {@link WrapperFactory#execute(AsyncCallable)} directly, which 
achieves the same thing.
-         * </p>
-         */
-        @Inject transient WrapperFactory wrapperFactory;
-
-        /**
-         * If the {@link java.util.concurrent.ExecutorService} used to execute 
this task (as defined by
-         * {@link AsyncControl#with(ExecutorService)} is not custom, then it 
can simply invoke this method, but it is
-         * important that it has not serialized/deserialized the object since 
important transient state would be lost.
-         *
-         * <p>
-         *  On the other hand, a custom implementation of {@link 
ExecutorService} is free to serialize this object, and
-         *  deserialize it later.  When deserializing it can either 
reinitialize the necessary state using the
-         *  {@link ServiceInjector} service, then call this method, or it can 
instead call
-         *  {@link WrapperFactory#execute(AsyncCallable)} directly, which 
achieves the same thing.
-         * </p>
-         */
-        @Override
-        public R call() {
-            if (wrapperFactory == null) {
-                throw new IllegalStateException(
-                        "The transient wrapperFactory is null; suggests that 
this async task been serialized and " +
-                        "then deserialized, but is now being executed by an 
ExecutorService that has not re-injected necessary services.");
-            }
-            return wrapperFactory.execute(this);
-        }
-    }
-
-    @Override
-    public <R> R execute(final AsyncCallable<R> asyncCallable) {
-        getServiceInjector().injectServicesInto(this);
-        final R result = interactionServiceProvider.get()
-                .call(asyncCallable.getInteractionContext(),
-                        () -> 
updateDomainObjectHonoringTransactionalPropagation(asyncCallable));
-        return result;
-    }
-
-    private <R> R updateDomainObjectHonoringTransactionalPropagation(final 
AsyncCallable<R> asyncCallable) {
-        return transactionServiceProvider.get()
-                .callTransactional(asyncCallable.getPropagation(),
-                        () -> updateDomainObject(asyncCallable))
-                .ifFailureFail()
-                .getValue().orElse(null);
-    }
-
-    private <R> R updateDomainObject(final AsyncCallable<R> asyncCallable) {
-
-        // obtain the Command that is implicitly created (initially mainly 
empty) whenever an Interaction is started.
-        var childCommand = 
interactionProviderProvider.get().currentInteractionElseFail().getCommand();
-
-        // we will "take over" this Command, updating it with the 
parentInteractionId of the command for the action
-        // that called WrapperFactory#asyncMixin in the first place.
-        
childCommand.updater().setParentInteractionId(asyncCallable.getParentInteractionId());
-
-        var tryBookmark = 
commandExecutorServiceProvider.get().executeCommand(asyncCallable.getCommandDto());
-
-        return tryBookmark.fold(
-                throwable -> null,                  // failure
-                bookmarkIfAny -> bookmarkIfAny.map( // success
-                    bookmark -> {
-                        var spec = 
getSpecificationLoader().specForBookmark(bookmark).orElse(null);
-                        if(spec==null) {
-                            return null;
-                        }
-                        R domainObject = 
bookmarkServiceProvider.get().lookup(bookmark, 
asyncCallable.getReturnType()).orElse(null);
-                        if(spec.isEntity()) {
-                            domainObject = 
repositoryServiceProvider.get().detach(domainObject);
-                        }
-                        return domainObject;
-                    }).orElse(null));
-    }
-
     private final static int MIN_POOL_SIZE = 2; // at least 2
     private final static int MAX_POOL_SIZE = 4; // max 4
     private ExecutorService newCommonExecutorService() {
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecord.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecord.java
new file mode 100644
index 00000000000..ee5235fef70
--- /dev/null
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecord.java
@@ -0,0 +1,31 @@
+/*
+ *  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.core.runtimeservices.wrapper.handlers;
+
+import java.util.UUID;
+
+import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
+import org.apache.causeway.schema.cmd.v2.CommandDto;
+
+record CommandRecord(
+        InteractionContext interactionContext,
+        CommandDto commandDto,
+        UUID parentInteractionId) {
+
+}
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecordFactory.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecordFactory.java
new file mode 100644
index 00000000000..6b19783527a
--- /dev/null
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/CommandRecordFactory.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.core.runtimeservices.wrapper.handlers;
+
+import org.apache.causeway.commons.collections.Can;
+import org.apache.causeway.core.metamodel.interactions.InteractionHead;
+import org.apache.causeway.core.metamodel.object.ManagedObject;
+import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
+import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
+import org.apache.causeway.core.runtimeservices.session.InteractionIdGenerator;
+
+record CommandRecordFactory(
+        InteractionIdGenerator interactionIdGenerator) {
+
+    public CommandRecord forAction(InteractionHead head, ObjectAction act, 
Can<ManagedObject> args) {
+        return new CommandRecord(
+                
act.getInteractionService().currentInteractionContextElseFail(),
+                act.getCommandDtoFactory()
+                        .asCommandDto(interactionIdGenerator.interactionId(), 
head, act, args),
+                act.getInteractionService().currentInteractionElseFail()
+                        .getCommand()
+                        .getInteractionId());
+    }
+
+    public CommandRecord forProperty(InteractionHead head, OneToOneAssociation 
prop, ManagedObject arg) {
+        return new CommandRecord(
+                
prop.getInteractionService().currentInteractionContextElseFail(),
+                prop.getCommandDtoFactory()
+                        .asCommandDto(interactionIdGenerator.interactionId(), 
head, prop, arg),
+                prop.getInteractionService().currentInteractionElseFail()
+                        .getCommand()
+                        .getInteractionId());
+    }
+
+
+}
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
index 3c39d7c97a1..4e2587eabbf 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/DomainObjectInvocationHandler.java
@@ -24,6 +24,8 @@
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
+import org.jspecify.annotations.Nullable;
+
 import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.exceptions.recoverable.InteractionException;
 import org.apache.causeway.applib.services.wrapper.DisabledException;
@@ -45,6 +47,7 @@
 import org.apache.causeway.commons.internal.reflection._GenericResolver;
 import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
 import org.apache.causeway.core.metamodel.consent.InteractionResult;
+import org.apache.causeway.core.metamodel.context.MetaModelContext;
 import org.apache.causeway.core.metamodel.facets.ImperativeFacet;
 import org.apache.causeway.core.metamodel.facets.ImperativeFacet.Intent;
 import org.apache.causeway.core.metamodel.interactions.InteractionHead;
@@ -76,16 +79,19 @@ final class DomainObjectInvocationHandler
     @Getter(onMethod_ = {@Override}) @Accessors(fluent=true)
     private final String key;
 
-    private final ProxyGenerator proxyGenerator;
     private final ObjectSpecification targetSpec;
+    private final ProxyGenerator proxyGenerator;
+    private final CommandRecordFactory commandRecordFactory;
 
     DomainObjectInvocationHandler(
             final ObjectSpecification targetSpec,
-            final ProxyGenerator proxyGenerator) {
+            final ProxyGenerator proxyGenerator,
+            final CommandRecordFactory commandRecordFactory) {
         this.targetSpec = targetSpec;
         this.classMetaData = 
WrapperInvocationHandler.ClassMetaData.of(targetSpec.getCorrespondingClass());
         this.proxyGenerator = proxyGenerator;
         this.key = targetSpec.getCorrespondingClass().getName();
+        this.commandRecordFactory = commandRecordFactory;
     }
 
     @Override
@@ -100,7 +106,7 @@ public Object invoke(WrapperInvocation wrapperInvocation) 
throws Throwable {
             return method.invoke(target, wrapperInvocation.args());
         }
 
-        final ManagedObject targetAdapter = 
targetSpec.getObjectManager().adapt(target);
+        final ManagedObject targetAdapter = 
mmc().getObjectManager().adapt(target);
 
         if(!targetAdapter.specialization().isMixin()) {
             MmAssertionUtils.assertIsBookmarkSupported(targetAdapter);
@@ -161,14 +167,12 @@ public Object invoke(WrapperInvocation wrapperInvocation) 
throws Throwable {
             }
         }
 
-        if (objectMember instanceof ObjectAction) {
+        if (objectMember instanceof ObjectAction objectAction) {
 
             if (intent == Intent.CHECK_IF_VALID) {
                 throw new UnsupportedOperationException(String.format("Cannot 
invoke supporting method '%s'; use only the 'invoke' method", 
objectMember.getId()));
             }
 
-            var objectAction = (ObjectAction) objectMember;
-
             if(targetAdapter.objSpec().isMixin()) {
                 if (managedMixee == null) {
                     throw _Exceptions.illegalState(
@@ -227,9 +231,9 @@ private static ObjectMember determineMixinMember(
     }
 
     private InteractionInitiatedBy getInteractionInitiatedBy(final 
WrapperInvocation wrapperInvocation) {
-        return wrapperInvocation.shouldEnforceRules()
-                ? InteractionInitiatedBy.USER
-                : InteractionInitiatedBy.FRAMEWORK;
+        return wrapperInvocation.syncControl().isSkipRules()
+                ? InteractionInitiatedBy.FRAMEWORK
+                : InteractionInitiatedBy.USER;
     }
 
     private boolean isEnhancedEntityMethod(final Method method) {
@@ -246,7 +250,7 @@ private Object handleTitleMethod(
         var titleContext = targetNoSpec
                 .createTitleInteractionContext(targetAdapter, 
InteractionInitiatedBy.FRAMEWORK);
         var titleEvent = titleContext.createInteractionEvent();
-        targetSpec.getWrapperFactory().notifyListeners(titleEvent);
+        mmc().getWrapperFactory().notifyListeners(titleEvent);
         return titleEvent.getTitle();
     }
 
@@ -261,6 +265,8 @@ private Object handleSaveMethod(
             notifyListenersAndVetoIfRequired(interactionResult);
         });
 
+        handleCommandListeners(wrapperInvocation, ()->null); //FIXME
+
         var spec = targetAdapter.objSpec();
         if(spec.isEntity()) {
             return runExecutionTask(wrapperInvocation, ()->{
@@ -283,6 +289,8 @@ private Object handleGetterMethodOnProperty(
             checkVisibility(wrapperInvocation, targetAdapter, property);
         });
 
+        handleCommandListeners(wrapperInvocation, ()->null); //FIXME
+
         return runExecutionTask(wrapperInvocation, ()->{
 
             var interactionInitiatedBy = 
getInteractionInitiatedBy(wrapperInvocation);
@@ -290,7 +298,7 @@ private Object handleGetterMethodOnProperty(
 
             var currentReferencedObj = 
MmUnwrapUtils.single(currentReferencedAdapter);
 
-            targetSpec.getWrapperFactory().notifyListeners(new 
PropertyAccessEvent(
+            mmc().getWrapperFactory().notifyListeners(new PropertyAccessEvent(
                     targetAdapter.getPojo(),
                     property.getFeatureIdentifier(),
                     currentReferencedObj));
@@ -321,6 +329,9 @@ targetAdapter, argumentAdapter, 
getInteractionInitiatedBy(wrapperInvocation))
             notifyListenersAndVetoIfRequired(interactionResult);
         });
 
+        handleCommandListeners(wrapperInvocation, ()->commandRecordFactory
+                .forProperty(InteractionHead.regular(targetAdapter), property, 
argumentAdapter));
+
         return runExecutionTask(wrapperInvocation, ()->{
             property.set(targetAdapter, argumentAdapter, 
getInteractionInitiatedBy(wrapperInvocation));
             return null;
@@ -340,6 +351,8 @@ private Object handleGetterMethodOnCollection(
             checkVisibility(wrapperInvocation, targetAdapter, collection);
         });
 
+        handleCommandListeners(wrapperInvocation, ()->null); //FIXME
+
         return runExecutionTask(wrapperInvocation, ()->{
 
             var interactionInitiatedBy = 
getInteractionInitiatedBy(wrapperInvocation);
@@ -353,13 +366,13 @@ private Object handleGetterMethodOnCollection(
                 var collectionViewObject = wrapCollection(
                         (Collection<?>) currentReferencedObj,
                         collection);
-                
targetSpec.getWrapperFactory().notifyListeners(collectionAccessEvent);
+                
mmc().getWrapperFactory().notifyListeners(collectionAccessEvent);
                 return collectionViewObject;
             } else if (currentReferencedObj instanceof Map) {
                 var mapViewObject = wrapMap(
                         (Map<?, ?>) currentReferencedObj,
                         collection);
-                
targetSpec.getWrapperFactory().notifyListeners(collectionAccessEvent);
+                
mmc().getWrapperFactory().notifyListeners(collectionAccessEvent);
                 return mapViewObject;
             }
 
@@ -412,6 +425,9 @@ private Object handleActionMethod(
             checkValidity(wrapperInvocation, head, objectAction, argAdapters);
         });
 
+        handleCommandListeners(wrapperInvocation, ()->commandRecordFactory
+                .forAction(head, objectAction, argAdapters));
+
         return runExecutionTask(wrapperInvocation, ()->{
             var interactionInitiatedBy = 
getInteractionInitiatedBy(wrapperInvocation);
 
@@ -419,9 +435,7 @@ private Object handleActionMethod(
                     head, argAdapters,
                     interactionInitiatedBy);
             return MmUnwrapUtils.single(returnedAdapter);
-
         });
-
     }
 
     private void checkValidity(
@@ -478,7 +492,7 @@ private void checkUsability(
     private void notifyListenersAndVetoIfRequired(final InteractionResult 
interactionResult) {
         var interactionEvent = interactionResult.getInteractionEvent();
 
-        targetSpec.getWrapperFactory().notifyListeners(interactionEvent);
+        mmc().getWrapperFactory().notifyListeners(interactionEvent);
         if (interactionEvent.isVeto()) {
             throw toException(interactionEvent);
         }
@@ -510,10 +524,12 @@ private InteractionException toException(final 
InteractionEvent interactionEvent
 
     // -- HELPER
 
+    private MetaModelContext mmc() {
+        return targetSpec.getMetaModelContext();
+    }
+
     private void runValidationTask(final WrapperInvocation wrapperInvocation, 
final Runnable task) {
-        if(!wrapperInvocation.shouldEnforceRules()) {
-            return;
-        }
+        if(wrapperInvocation.syncControl().isSkipRules()) return;
         try {
             task.run();
         } catch(Exception ex) {
@@ -521,10 +537,30 @@ private void runValidationTask(final WrapperInvocation 
wrapperInvocation, final
         }
     }
 
-    private <X> X runExecutionTask(final WrapperInvocation wrapperInvocation, 
final Supplier<X> task) {
-        if(!wrapperInvocation.shouldExecute()) {
-            return null;
+    /**
+     * regardless of to be executed or not,
+     * we inform any listeners of the execution intent (e.g. 
BackgroundExecutionService)
+     */
+    private void handleCommandListeners(
+            final WrapperInvocation wrapperInvocation,
+            final @Nullable Supplier<CommandRecord> commandRecordSupplier) {
+        if(commandRecordSupplier!=null
+                && 
wrapperInvocation.syncControl().commandListeners().isNotEmpty()) {
+            var commandRecord = commandRecordSupplier.get();
+            if(commandRecord==null) return;
+            wrapperInvocation.syncControl().commandListeners()
+                .forEach(listener->listener
+                        .onCommand(
+                                commandRecord.interactionContext(),
+                                commandRecord.commandDto(),
+                                commandRecord.parentInteractionId()));
         }
+    }
+
+    private <X> X runExecutionTask(
+            final WrapperInvocation wrapperInvocation,
+            final Supplier<X> task) {
+        if(wrapperInvocation.syncControl().isSkipExecute()) return null;
         try {
             return task.get();
         } catch(Exception ex) {
diff --git 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyGenerator.java
 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyGenerator.java
index 85a1e9a4466..b5e2b424adf 100644
--- 
a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyGenerator.java
+++ 
b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyGenerator.java
@@ -34,8 +34,15 @@
 import org.apache.causeway.core.metamodel.spec.feature.OneToManyAssociation;
 import org.apache.causeway.core.runtime.wrap.WrapperInvocationHandler;
 import org.apache.causeway.core.runtime.wrap.WrappingObject;
+import org.apache.causeway.core.runtimeservices.session.InteractionIdGenerator;
 
-public record ProxyGenerator(@NonNull ProxyFactoryService proxyFactoryService) 
{
+public record ProxyGenerator(
+        @NonNull ProxyFactoryService proxyFactoryService,
+        @NonNull CommandRecordFactory commandRecordFactory) {
+
+    public ProxyGenerator(ProxyFactoryService proxyFactoryService, 
InteractionIdGenerator interactionIdGenerator) {
+        this(proxyFactoryService, new 
CommandRecordFactory(interactionIdGenerator));
+    }
 
     @SuppressWarnings("unchecked")
     public <T> T objectProxy(
@@ -113,7 +120,7 @@ private <T, P> P instantiatePluralProxy(final Class<T> 
base, final PluralInvocat
     public WrapperInvocationHandler handler(ObjectSpecification targetSpec) {
         return new DomainObjectInvocationHandler(
                 targetSpec,
-                this);
+                this, commandRecordFactory);
     }
 
 }
diff --git 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
index a7489e78312..292a4e9711c 100644
--- 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
+++ 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/WrapperFactoryDefaultTest.java
@@ -50,7 +50,7 @@ public void __causeway_save() {
 
         @Override
         public WrappingObject.Origin __causeway_origin() {
-            return new WrappingObject.Origin(wrappedObject, 
SyncControl.control());
+            return new WrappingObject.Origin(wrappedObject, 
SyncControl.defaults());
         }
     }
 
@@ -117,7 +117,7 @@ public void 
wrap_ofWrapped_differentMode_delegates_to_createProxy() throws Excep
         final DomainObject domainObject = new 
WrappingDomainObject(wrappedObject);
 
         // when
-        final DomainObject wrappingObject = wrapperFactory.wrap(domainObject, 
SyncControl.control().withSkipRules());
+        final DomainObject wrappingObject = wrapperFactory.wrap(domainObject, 
SyncControl.defaults().withSkipRules());
 
         // then
         assertThat(wrappingObject, is(not(domainObject)));
diff --git 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyCreatorTestUsingCodegenPlugin.java
 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyCreatorTestUsingCodegenPlugin.java
index ea62989b938..d5f7cd91abb 100644
--- 
a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyCreatorTestUsingCodegenPlugin.java
+++ 
b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/wrapper/handlers/ProxyCreatorTestUsingCodegenPlugin.java
@@ -34,7 +34,7 @@
 
 class ProxyCreatorTestUsingCodegenPlugin extends RuntimeServicesTestAbstract {
 
-    private ProxyGenerator proxyGenerator = new ProxyGenerator(new 
ProxyFactoryServiceByteBuddy());
+    private ProxyGenerator proxyGenerator = new ProxyGenerator(new 
ProxyFactoryServiceByteBuddy(), new CommandRecordFactory(null));
 
     @DomainObject(nature = Nature.VIEW_MODEL)
     public static class Employee {
@@ -53,7 +53,7 @@ void proxyShouldDelegateCalls() {
         final Employee employee = new Employee();
         var employeeSpec = 
getMetaModelContext().getSpecificationLoader().loadSpecification(Employee.class);
 
-        var proxy = proxyGenerator.objectProxy(employee, employeeSpec, 
SyncControl.control());
+        var proxy = proxyGenerator.objectProxy(employee, employeeSpec, 
SyncControl.defaults());
 
         assertNotNull(proxy);
         assertTrue(proxy instanceof WrappingObject);
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 dc025903595..64303bf8c12 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
@@ -18,8 +18,6 @@
  */
 package org.apache.causeway.extensions.commandlog.applib;
 
-import 
org.apache.causeway.extensions.commandlog.applib.spi.RunBackgroundCommandsJobListener;
-
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Import;
 
@@ -36,6 +34,7 @@
 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;
+import 
org.apache.causeway.extensions.commandlog.applib.spi.RunBackgroundCommandsJobListener;
 import 
org.apache.causeway.extensions.commandlog.applib.subscriber.CommandSubscriberForCommandLog;
 
 @Configuration
@@ -62,7 +61,6 @@
         BackgroundCommandsJobControl.class,
 
         BackgroundService.class,
-        BackgroundService.PersistCommandExecutorService.class,
 
         FakeScheduler.class,
 })
diff --git 
a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/BackgroundService.java
 
b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/BackgroundService.java
index 9023f4e604f..ecc931eb0c2 100644
--- 
a/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/BackgroundService.java
+++ 
b/extensions/core/commandlog/applib/src/main/java/org/apache/causeway/extensions/commandlog/applib/dom/BackgroundService.java
@@ -19,15 +19,7 @@
 package org.apache.causeway.extensions.commandlog.applib.dom;
 
 import java.sql.Timestamp;
-import java.util.Collection;
-import java.util.List;
 import java.util.UUID;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
 
 import jakarta.inject.Inject;
 
@@ -36,9 +28,11 @@
 import org.apache.causeway.applib.jaxb.JavaSqlJaxbAdapters;
 import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.applib.services.command.Command;
+import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
-import org.apache.causeway.applib.services.wrapper.callable.AsyncCallable;
+import org.apache.causeway.applib.services.wrapper.WrapperFactory.AsyncProxy;
 import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
+import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.schema.cmd.v2.CommandDto;
 import org.apache.causeway.schema.common.v2.PeriodDto;
 
@@ -46,21 +40,19 @@
  * Allows the execution of action invocations or property edits to be deferred 
so that they can be executed later in
  * another thread of execution.
  *
- * <p>
- *     Typically this other thread of execution would be scheduled from quartz 
or similar.  The
- *     {@link 
org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob} 
provides a ready-made
- *     implementation to do this for quartz.
- * </p>
+ * <p>Typically this other thread of execution would be scheduled from quartz 
or similar.  The
+ * {@link 
org.apache.causeway.extensions.commandlog.applib.job.RunBackgroundCommandsJob} 
provides a ready-made
+ * implementation to do this for quartz.
  *
  * @see WrapperFactory
  * @see 
org.apache.causeway.extensions.commandlog.applib.fakescheduler.FakeScheduler
- * @since 2.0 {@index}
+ * @since 2.0 revised for 3.4 {@index}
  */
 @Service
 public class BackgroundService {
 
     @Inject WrapperFactory wrapperFactory;
-    @Inject PersistCommandExecutorService persistCommandExecutorService;
+    @Inject CommandLogEntryRepository commandLogEntryRepository;
 
     /**
      * Wraps the domain object in a proxy whereby any actions invoked through 
the proxy will instead be persisted as a
@@ -68,10 +60,11 @@ public class BackgroundService {
      *
      * @see #executeMixin(Class, Object) - to invoke actions that are 
implemented as mixins
      */
-    public <T> T execute(final T object) {
-        return wrapperFactory.asyncWrap(object, 
AsyncControl.returningVoid().withCheckRules()
-                .with(persistCommandExecutorService)
-        );
+    public <T> AsyncProxy<T> execute(final T object) {
+        return wrapperFactory.asyncWrap(object, AsyncControl.defaults()
+                .withNoExecute()
+                .withCheckRules()
+                .listen(new CommandPersistor(commandLogEntryRepository)));
     }
     /**
      * Wraps the domain object in a proxy whereby any actions invoked through 
the proxy will instead be persisted as a
@@ -79,10 +72,11 @@ public <T> T execute(final T object) {
      *
      * @see #executeMixin(Class, Object) - to invoke actions that are 
implemented as mixins
      */
-    public <T> T executeSkipRules(final T object) {
-        return wrapperFactory.asyncWrap(object, 
AsyncControl.returningVoid().withSkipRules()
-                .with(persistCommandExecutorService)
-        );
+    public <T> AsyncProxy<T> executeSkipRules(final T object) {
+        return wrapperFactory.asyncWrap(object, AsyncControl.defaults()
+                .withNoExecute()
+                .withSkipRules()
+                .listen(new CommandPersistor(commandLogEntryRepository)));
     }
 
     /**
@@ -91,10 +85,11 @@ public <T> T executeSkipRules(final T object) {
      *
      * @see #execute(Object) - to invoke actions that are implemented directly 
within the object
      */
-    public <T> T executeMixin(final Class<T> mixinClass, final Object mixedIn) 
{
-        return wrapperFactory.asyncWrapMixin(mixinClass, mixedIn, 
AsyncControl.returningVoid().withCheckRules()
-                .with(persistCommandExecutorService)
-        );
+    public <T> AsyncProxy<T> executeMixin(final Class<T> mixinClass, final 
Object mixedIn) {
+        return wrapperFactory.asyncWrapMixin(mixinClass, mixedIn, 
AsyncControl.defaults()
+                .withNoExecute()
+                .withCheckRules()
+                .listen(new CommandPersistor(commandLogEntryRepository)));
     }
 
     /**
@@ -103,39 +98,28 @@ public <T> T executeMixin(final Class<T> mixinClass, final 
Object mixedIn) {
      *
      * @see #execute(Object) - to invoke actions that are implemented directly 
within the object
      */
-    public <T> T executeMixinSkipRules(final Class<T> mixinClass, final Object 
mixedIn) {
-        return wrapperFactory.asyncWrapMixin(mixinClass, mixedIn, 
AsyncControl.returningVoid().withSkipRules()
-                .with(persistCommandExecutorService)
-        );
+    public <T> AsyncProxy<T> executeMixinSkipRules(final Class<T> mixinClass, 
final Object mixedIn) {
+        return wrapperFactory.asyncWrapMixin(mixinClass, mixedIn, 
AsyncControl.defaults()
+                .withNoExecute()
+                .withSkipRules()
+                .listen(new CommandPersistor(commandLogEntryRepository)));
     }
 
-    /**
-     * @since 2.0 {@index}
-     */
-    @Service
-    public static class PersistCommandExecutorService implements 
ExecutorService {
-
-        @Inject CommandLogEntryRepository commandLogEntryRepository;
-
-        private final static 
JavaSqlJaxbAdapters.TimestampToXMLGregorianCalendarAdapter 
gregorianCalendarAdapter  = new 
JavaSqlJaxbAdapters.TimestampToXMLGregorianCalendarAdapter();;
+    record CommandPersistor(CommandLogEntryRepository 
commandLogEntryRepository) implements SyncControl.CommandListener {
 
         @Override
-        public <T> Future<T> submit(final Callable<T> task) {
-            var callable = (AsyncCallable<T>) task;
-            var commandDto = callable.getCommandDto();
+        public void onCommand(
+                final InteractionContext interactionContext,
+                final CommandDto commandDto,
+                final UUID parentInteractionId) {
 
             // we'll mutate the commandDto in line with the callable, then
             // create the CommandLogEntry from that commandDto
-            var childInteractionId = UUID.randomUUID();
-            commandDto.setInteractionId(childInteractionId.toString());
+            commandDto.setInteractionId(UUID.randomUUID().toString());
 
             // copy details from requested interaction context into the 
commandDto
-            var interactionContext = callable.getInteractionContext();
-            var timestamp = 
interactionContext.getClock().nowAsJavaSqlTimestamp();
-            
commandDto.setTimestamp(gregorianCalendarAdapter.marshal(timestamp));
-
-            var username = interactionContext.getUser().name();
-            commandDto.setUsername(username);
+            
commandDto.setTimestamp(GREGORIAN_CALENDAR_ADAPTER.marshal(interactionContext.getClock().nowAsJavaSqlTimestamp()));
+            commandDto.setUsername(interactionContext.getUser().name());
 
             var periodDto = new PeriodDto();
             periodDto.setStartedAt(null);
@@ -144,110 +128,27 @@ public <T> Future<T> submit(final Callable<T> task) {
 
             var childCommand = newCommand(commandDto);
 
-            commandLogEntryRepository.createEntryAndPersist(childCommand, 
callable.getParentInteractionId(), ExecuteIn.BACKGROUND);
-
-            // a more sophisticated implementation could perhaps return a 
Future that supports these methods by
-            // querying the CommandLogEntryRepository
-            return new Future<T>() {
-                @Override
-                public boolean cancel(final boolean mayInterruptIfRunning) {
-                    throw new IllegalStateException("Not implemented");
-                }
-                @Override
-                public boolean isCancelled() {
-                    throw new IllegalStateException("Not implemented");
-                }
-
-                @Override
-                public boolean isDone() {
-                    throw new IllegalStateException("Not implemented");
-                }
-
-                @Override
-                public T get() {
-                    throw new IllegalStateException("Not implemented");
-                }
-
-                @Override
-                public T get(final long timeout, final TimeUnit unit) {
-                    throw new IllegalStateException("Not implemented");
-                }
-            };
+            commandLogEntryRepository.createEntryAndPersist(childCommand, 
parentInteractionId, ExecuteIn.BACKGROUND);
         }
 
+        // -- HELPER
+
+        private final static 
JavaSqlJaxbAdapters.TimestampToXMLGregorianCalendarAdapter 
GREGORIAN_CALENDAR_ADAPTER
+            = new JavaSqlJaxbAdapters.TimestampToXMLGregorianCalendarAdapter();
+
         private static Command newCommand(final CommandDto commandDto) {
             return new Command(UUID.fromString(commandDto.getInteractionId())) 
{
                 @Override public String getUsername() {return 
commandDto.getUsername();}
-                @Override public Timestamp getTimestamp() {return 
gregorianCalendarAdapter.unmarshal(commandDto.getTimestamp());}
+                @Override public Timestamp getTimestamp() {return 
GREGORIAN_CALENDAR_ADAPTER.unmarshal(commandDto.getTimestamp());}
                 @Override public CommandDto getCommandDto() {return 
commandDto;}
                 @Override public String getLogicalMemberIdentifier() {return 
commandDto.getMember().getLogicalMemberIdentifier();}
                 @Override public Bookmark getTarget() {return 
Bookmark.forOidDto(commandDto.getTargets().getOid().get(0));}
-                @Override public Timestamp getStartedAt() {return 
gregorianCalendarAdapter.unmarshal(commandDto.getTimings().getStartedAt());}
-                @Override public Timestamp getCompletedAt() {return 
gregorianCalendarAdapter.unmarshal(commandDto.getTimings().getCompletedAt());}
+                @Override public Timestamp getStartedAt() {return 
GREGORIAN_CALENDAR_ADAPTER.unmarshal(commandDto.getTimings().getStartedAt());}
+                @Override public Timestamp getCompletedAt() {return 
GREGORIAN_CALENDAR_ADAPTER.unmarshal(commandDto.getTimings().getCompletedAt());}
                 @Override public Bookmark getResult() {return null;}
                 @Override public Throwable getException() {return null;}
             };
         }
 
-        @Override
-        public <T> Future<T> submit(final Runnable task, final T result) {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public Future<?> submit(final Runnable task) {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public void execute(final Runnable command) {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public <T> List<Future<T>> invokeAll(final Collection<? extends 
Callable<T>> tasks) {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public <T> List<Future<T>> invokeAll(final Collection<? extends 
Callable<T>> tasks, final long timeout, final TimeUnit unit) throws 
InterruptedException {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public <T> T invokeAny(final Collection<? extends Callable<T>> tasks) 
throws InterruptedException, ExecutionException {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public <T> T invokeAny(final Collection<? extends Callable<T>> tasks, 
final long timeout, final TimeUnit unit) throws InterruptedException, 
ExecutionException, TimeoutException {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public void shutdown() {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public List<Runnable> shutdownNow() {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public boolean awaitTermination(final long timeout, final TimeUnit 
unit) throws InterruptedException {
-            throw new IllegalStateException("Not implemented");
-        }
-
-        @Override
-        public boolean isShutdown() {
-            return false;
-        }
-
-        @Override
-        public boolean isTerminated() {
-            return false;
-        }
-
     }
 }
diff --git 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
index 6ec96cd451a..5a3e3dce8e4 100644
--- 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
+++ 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/BackgroundService_IntegTestAbstract.java
@@ -19,6 +19,7 @@
 package org.apache.causeway.extensions.commandlog.applib.integtest;
 
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 import jakarta.inject.Inject;
 
@@ -62,7 +63,7 @@ public abstract class BackgroundService_IntegTestAbstract 
extends CausewayIntegr
 
     Bookmark bookmark;
 
-    protected abstract Counter newCounter(String name);
+    protected abstract <T extends Counter> T newCounter(String name);
 
     private static boolean prototypingOrig;
 
@@ -84,7 +85,7 @@ void setup_counter() {
             counterRepository.removeAll();
 
             counterRepository.persist(newCounter("fred"));
-            List<Counter> counters = counterRepository.find();
+            List<? extends Counter> counters = counterRepository.find();
             assertThat(counters).hasSize(1);
 
             bookmark = bookmarkService.bookmarkForElseFail(counters.get(0));
@@ -104,11 +105,12 @@ void async_using_default_executor_service() {
         transactionService.runTransactional(Propagation.REQUIRES_NEW, () -> {
             var counter = bookmarkService.lookup(bookmark, 
Counter.class).orElseThrow();
 
-            var control = AsyncControl.returning(Counter.class);
-            wrapperFactory.asyncWrap(counter, 
control).bumpUsingDeclaredAction();
+            var control = AsyncControl.defaults();
+            wrapperFactory.asyncWrap(counter, control)
+                .thenApplyAsync(Counter::bumpUsingDeclaredAction)
+                .orTimeout(5, TimeUnit.SECONDS)
+                .join(); // wait till done
 
-            // wait till done
-            control.future().get();
         }).ifFailureFail();
 
         // then
@@ -123,13 +125,12 @@ void async_using_default_executor_service() {
             assertThat(counter.getNum()).isEqualTo(1L);
 
             // when
-            var control = AsyncControl.returning(Counter.class);
-            wrapperFactory.asyncWrapMixin(Counter_bumpUsingMixin.class, 
counter, control).act();
+            var control = AsyncControl.defaults();
+            wrapperFactory.asyncWrapMixin(Counter_bumpUsingMixin.class, 
counter, control)
+                .thenApplyAsync(Counter_bumpUsingMixin::act)
+                .join(); // wait till done
 
-            // wait til done
-            control.future().get();
         }).ifFailureFail();
-
         // then
         transactionService.runTransactional(Propagation.REQUIRES_NEW, () -> {
             var counter = bookmarkService.lookup(bookmark, 
Counter.class).orElseThrow();
@@ -151,9 +152,11 @@ void using_background_service() {
             assertThat(counter.getNum()).isNull();
 
             // when
-            backgroundService.execute(counter).bumpUsingDeclaredAction();
+            backgroundService.execute(counter)
+                .thenAcceptAsync(Counter::bumpUsingDeclaredAction)
+                .orTimeout(1, TimeUnit.SECONDS)
+                .join(); // wait for completion
 
-            Thread.sleep(1_000);// horrid, but let's just wait 1 sec before 
testing
         }).ifFailureFail();
 
         // then no change to the counter
@@ -219,12 +222,11 @@ private void removeAllCommandLogEntriesAndCounters() {
 
     @Inject InteractionService interactionService;
     @Inject BackgroundService backgroundService;
-    @Inject BackgroundService.PersistCommandExecutorService 
persistCommandExecutorService;
     @Inject WrapperFactory wrapperFactory;
     @Inject CommandLogEntryRepository commandLogEntryRepository;
     @Inject TransactionService transactionService;
     @Inject RunBackgroundCommandsJob runBackgroundCommandsJob;
     @Inject BookmarkService bookmarkService;
-    @Inject CounterRepository counterRepository;
+    @Inject CounterRepository<? extends Counter> counterRepository;
 
 }
diff --git 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
index 06fd7efbf5b..2a5d2951337 100644
--- 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
+++ 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/CommandLog_IntegTestAbstract.java
@@ -84,7 +84,7 @@ void beforeEach() {
         assertThat(mostRecentCompleted).isEmpty();
     }
 
-    protected abstract Counter newCounter(String name);
+    protected abstract <T extends Counter>  T newCounter(String name);
 
     @Test
     void invoke_mixin() {
@@ -243,7 +243,7 @@ void roundtrip_CLE_bookmarks() {
         // then
         assertThat(cleBookmarkIfAny).isPresent();
         Bookmark cleBookmark = cleBookmarkIfAny.get();
-        String identifier = cleBookmark.getIdentifier();
+        String identifier = cleBookmark.identifier();
         if (causewayBeanTypeRegistry.persistenceStack().isJdo()) {
             assertThat(identifier).startsWith("u_");
             UUID.fromString(identifier.substring("u_".length())); // should 
not fail, ie check the format is as we expect
@@ -351,6 +351,7 @@ void test_all_the_repository_methods() {
         // given
         commandTarget1User1 = commandTarget1User1ById.get();
         commandTarget1User2 = commandTarget1User2ById.get();
+        @SuppressWarnings("unused")
         var commandTarget1User1Yesterday = 
commandTarget1User1YesterdayById.get();
         commandTarget2User1 = commandTarget2User1ById.get();
 
@@ -468,7 +469,7 @@ void test_all_the_repository_methods() {
     @Inject ClockService clockService;
     @Inject InteractionService interactionService;
     @Inject InteractionLayerTracker interactionLayerTracker;
-    @Inject CounterRepository counterRepository;
+    @Inject CounterRepository<? extends Counter> counterRepository;
     @Inject WrapperFactory wrapperFactory;
     @Inject BookmarkService bookmarkService;
     @Inject CausewayBeanTypeRegistry causewayBeanTypeRegistry;
diff --git 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/model/CounterRepository.java
 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/model/CounterRepository.java
index e37a77b45e1..5ee1286cd84 100644
--- 
a/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/model/CounterRepository.java
+++ 
b/extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/model/CounterRepository.java
@@ -31,7 +31,7 @@ public abstract class CounterRepository<X extends Counter> {
 
     private final Class<X> counterClass;
 
-    public CounterRepository(Class<X> counterClass) {
+    public CounterRepository(final Class<X> counterClass) {
         this.counterClass = counterClass;
     }
 
@@ -39,7 +39,7 @@ public List<X> find() {
         return repositoryService.allInstances(counterClass);
     }
 
-    public X persist(X counter) {
+    public X persist(final X counter) {
         return repositoryService.persistAndFlush(counter);
     }
 
@@ -49,7 +49,7 @@ public void removeAll() {
 
     @Inject RepositoryService repositoryService;
 
-    public Counter findByName(String name) {
+    public X findByName(final String name) {
         List<X> xes = find();
         return xes.stream().filter(x -> Objects.equals(x.getName(), 
name)).findFirst().orElseThrow();
     }
diff --git 
a/regressiontests/base-jdo/src/main/java/org/apache/causeway/testdomain/jdo/publishing/PublishingTestFactoryJdo.java
 
b/regressiontests/base-jdo/src/main/java/org/apache/causeway/testdomain/jdo/publishing/PublishingTestFactoryJdo.java
index bce2af5a117..e48f9aa88ca 100644
--- 
a/regressiontests/base-jdo/src/main/java/org/apache/causeway/testdomain/jdo/publishing/PublishingTestFactoryJdo.java
+++ 
b/regressiontests/base-jdo/src/main/java/org/apache/causeway/testdomain/jdo/publishing/PublishingTestFactoryJdo.java
@@ -31,12 +31,14 @@
 
 import org.springframework.context.annotation.Import;
 import org.springframework.stereotype.Component;
+import org.springframework.util.function.ThrowingFunction;
 
 import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.services.factory.FactoryService;
 import org.apache.causeway.applib.services.iactnlayer.InteractionService;
 import org.apache.causeway.applib.services.repository.RepositoryService;
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
+import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.xactn.TransactionService;
 import org.apache.causeway.commons.collections.Can;
@@ -53,8 +55,6 @@
 import 
org.apache.causeway.testdomain.publishing.PublishingTestFactoryAbstract.CommitListener;
 import org.apache.causeway.testdomain.util.dto.BookDto;
 
-import static 
org.apache.causeway.applib.services.wrapper.control.AsyncControl.returningVoid;
-
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
@@ -278,7 +278,7 @@ protected void wrapperSyncExecutionNoRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withSkipRules(); // 
don't enforce rules
+                var syncControl = SyncControl.defaults().withSkipRules(); // 
don't enforce rules
                 context.changeProperty(()->wrapper.wrap(book, 
syncControl).setName("Book #2"));
 
             });
@@ -291,7 +291,7 @@ protected void wrapperSyncExecutionNoRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withSkipRules(); // 
don't enforce rules
+                var syncControl = SyncControl.defaults().withSkipRules(); // 
don't enforce rules
                 context.executeAction(()->wrapper.wrap(book, 
syncControl).doubleThePrice());
 
             });
@@ -318,7 +318,7 @@ protected void wrapperSyncExecutionWithRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withCheckRules(); // 
enforce rules
+                var syncControl = SyncControl.defaults().withCheckRules(); // 
enforce rules
 
                 //assertThrows(DisabledException.class, ()->{
                     wrapper.wrap(book, syncControl).setName("Book #2"); // 
should fail with DisabledException
@@ -334,7 +334,7 @@ protected void wrapperSyncExecutionWithRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withCheckRules(); // 
enforce rules
+                var syncControl = SyncControl.defaults().withCheckRules(); // 
enforce rules
 
                 //assertThrows(DisabledException.class, ()->{
                     wrapper.wrap(book, syncControl).doubleThePrice(); // 
should fail with DisabledException
@@ -358,20 +358,19 @@ protected void wrapperAsyncExecutionNoRules(
         context.bind(commitListener);
 
         // given
-        var asyncControl = returningVoid().withSkipRules(); // don't enforce 
rules
-
-        withBookDo(book->{
+        var asyncControl = AsyncControl.defaults().withSkipRules(); // don't 
enforce rules
 
+        var future = withBookCall(book->{
             context.runGiven();
 
             // when - running asynchronous
-            wrapper.asyncWrap(book, asyncControl)
-            .setName("Book #2");
-
+            return wrapper.asyncWrap(book, asyncControl)
+                    .thenAcceptAsync(bk->bk.setName("Book #2"));
         });
 
-        asyncControl.getFuture().get(10, TimeUnit.SECONDS);
-
+        future
+            .orTimeout(10, TimeUnit.SECONDS)
+            .join(); // wait till done
     }
 
     @Override
@@ -385,11 +384,12 @@ protected void wrapperAsyncExecutionWithRules(
             context.runGiven();
 
             // when - running synchronous
-            var asyncControl = returningVoid().withCheckRules(); // enforce 
rules
+            var asyncControl = AsyncControl.defaults().withCheckRules(); // 
enforce rules
 
             //assertThrows(DisabledException.class, ()->{
                 // should fail with DisabledException (synchronous) within the 
calling Thread
-                wrapper.asyncWrap(book, asyncControl).setName("Book #2");
+                wrapper.asyncWrap(book, asyncControl)
+                    .thenAcceptAsync(bk->bk.setName("Book #2"));
 
             //});
 
@@ -397,7 +397,7 @@ protected void wrapperAsyncExecutionWithRules(
 
     }
 
-    // -- TEST SETUP
+    // -- HELPER
 
     @SneakyThrows
     private void withBookDo(final CheckedConsumer<JdoBook> 
transactionalBookConsumer) {
@@ -405,4 +405,10 @@ private void withBookDo(final CheckedConsumer<JdoBook> 
transactionalBookConsumer
         transactionalBookConsumer.accept(book);
     }
 
+    @SneakyThrows
+    private <T> T withBookCall(final ThrowingFunction<JdoBook, T> 
transactionalBookFunction) {
+        var book = 
repository.allInstances(JdoBook.class).listIterator().next();
+        return transactionalBookFunction.apply(book);
+    }
+
 }
diff --git 
a/regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/publishing/PublishingTestFactoryJpa.java
 
b/regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/publishing/PublishingTestFactoryJpa.java
index f3091aaa6ac..dfd7a5212c4 100644
--- 
a/regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/publishing/PublishingTestFactoryJpa.java
+++ 
b/regressiontests/base-jpa/src/main/java/org/apache/causeway/testdomain/jpa/publishing/PublishingTestFactoryJpa.java
@@ -29,12 +29,14 @@
 
 import org.springframework.context.annotation.Import;
 import org.springframework.stereotype.Component;
+import org.springframework.util.function.ThrowingFunction;
 
 import org.apache.causeway.applib.annotation.Where;
 import org.apache.causeway.applib.services.factory.FactoryService;
 import org.apache.causeway.applib.services.iactnlayer.InteractionService;
 import org.apache.causeway.applib.services.repository.RepositoryService;
 import org.apache.causeway.applib.services.wrapper.WrapperFactory;
+import org.apache.causeway.applib.services.wrapper.control.AsyncControl;
 import org.apache.causeway.applib.services.wrapper.control.SyncControl;
 import org.apache.causeway.applib.services.xactn.TransactionService;
 import org.apache.causeway.commons.collections.Can;
@@ -55,8 +57,6 @@
 import org.apache.causeway.testdomain.util.dto.BookDto;
 import 
org.apache.causeway.testing.fixtures.applib.fixturescripts.FixtureScripts;
 
-import static 
org.apache.causeway.applib.services.wrapper.control.AsyncControl.returningVoid;
-
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
@@ -273,7 +273,7 @@ protected void wrapperSyncExecutionNoRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withSkipRules(); // 
don't enforce rules
+                var syncControl = SyncControl.defaults().withSkipRules(); // 
don't enforce rules
                 context.changeProperty(()->wrapper.wrap(book, 
syncControl).setName("Book #2"));
 
             });
@@ -286,7 +286,7 @@ protected void wrapperSyncExecutionNoRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withSkipRules(); // 
don't enforce rules
+                var syncControl = SyncControl.defaults().withSkipRules(); // 
don't enforce rules
                 context.executeAction(()->wrapper.wrap(book, 
syncControl).doubleThePrice());
 
             });
@@ -313,7 +313,7 @@ protected void wrapperSyncExecutionWithRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withCheckRules(); // 
enforce rules
+                var syncControl = SyncControl.defaults().withCheckRules(); // 
enforce rules
 
                 //assertThrows(DisabledException.class, ()->{
                     wrapper.wrap(book, syncControl).setName("Book #2"); // 
should throw DisabledException
@@ -329,7 +329,7 @@ protected void wrapperSyncExecutionWithRules(
                 context.runGiven();
 
                 // when - running synchronous
-                var syncControl = SyncControl.control().withCheckRules(); // 
enforce rules
+                var syncControl = SyncControl.defaults().withCheckRules(); // 
enforce rules
 
                 //assertThrows(DisabledException.class, ()->{
                     wrapper.wrap(book, syncControl).doubleThePrice(); // 
should throw DisabledException
@@ -353,21 +353,19 @@ protected void wrapperAsyncExecutionNoRules(
         context.bind(commitListener);
 
         // given
-        var asyncControl = returningVoid().withSkipRules(); // don't enforce 
rules
-
-        // when
-
-        withBookDo(book->{
+        var asyncControl = AsyncControl.defaults().withSkipRules(); // don't 
enforce rules
 
+        var future = withBookCall(book->{
             context.runGiven();
 
             // when - running asynchronous
-            wrapper.asyncWrap(book, asyncControl)
-            .setName("Book #2");
-
+            return wrapper.asyncWrap(book, asyncControl)
+                    .thenAcceptAsync(bk->bk.setName("Book #2"));
         });
 
-        asyncControl.getFuture().get(10, TimeUnit.SECONDS);
+        future
+            .orTimeout(10, TimeUnit.SECONDS)
+            .join(); // wait till done
 
     }
 
@@ -381,11 +379,12 @@ protected void wrapperAsyncExecutionWithRules(
         withBookDo(book->{
 
             // when - running synchronous
-            var asyncControl = returningVoid().withCheckRules(); // enforce 
rules
+            var asyncControl = AsyncControl.defaults().withCheckRules(); // 
enforce rules
 
             //assertThrows(DisabledException.class, ()->{
                 // should fail with DisabledException (synchronous) within the 
calling Thread
-                wrapper.asyncWrap(book, asyncControl).setName("Book #2");
+            wrapper.asyncWrap(book, asyncControl)
+                .thenAcceptAsync(bk->bk.setName("Book #2"));
 
             //});
 
@@ -425,16 +424,20 @@ private void setupBookForJpa() {
         em.flush();
     }
 
-    @SneakyThrows
-    private void withBookDo(final CheckedConsumer<JpaBook> bookConsumer) {
+    // -- HELPER
 
+    @SneakyThrows
+    private void withBookDo(final CheckedConsumer<JpaBook> 
transactionalBookConsumer) {
         //var em = jpaSupport.getEntityManagerElseFail(JpaBook.class);
-
         var book = 
repository.allInstances(JpaBook.class).listIterator().next();
-        bookConsumer.accept(book);
-
+        transactionalBookConsumer.accept(book);
         //em.flush(); // in effect makes changes visible during PRE_COMMIT
+    }
 
+    @SneakyThrows
+    private <T> T withBookCall(final ThrowingFunction<JpaBook, T> 
transactionalBookFunction) {
+        var book = 
repository.allInstances(JpaBook.class).listIterator().next();
+        return transactionalBookFunction.apply(book);
     }
 
 }
diff --git 
a/regressiontests/core-wrapperfactory/src/test/java/org/apache/causeway/regressiontests/core/wrapperfactory/integtests/WrapperFactory_async_IntegTest.java
 
b/regressiontests/core-wrapperfactory/src/test/java/org/apache/causeway/regressiontests/core/wrapperfactory/integtests/WrapperFactory_async_IntegTest.java
index 005ec326d51..2721f98a817 100644
--- 
a/regressiontests/core-wrapperfactory/src/test/java/org/apache/causeway/regressiontests/core/wrapperfactory/integtests/WrapperFactory_async_IntegTest.java
+++ 
b/regressiontests/core-wrapperfactory/src/test/java/org/apache/causeway/regressiontests/core/wrapperfactory/integtests/WrapperFactory_async_IntegTest.java
@@ -33,7 +33,6 @@
 import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.apache.causeway.applib.services.bookmark.Bookmark;
 import org.apache.causeway.applib.services.bookmark.BookmarkService;
@@ -83,14 +82,13 @@ void async_using_default_executor_service(final String 
displayName, final Execut
         runWithNewTransaction(() -> {
             var counter = bookmarkService.lookup(bookmark, 
Counter.class).orElseThrow();
 
-            var asyncControl = AsyncControl.returning(Counter.class)
+            var asyncControl = AsyncControl.defaults()
                     .with(executorService);
 
-            wrapperFactory.asyncWrap(counter, asyncControl).increment();
-
-            // let's wait max 5 sec to allow executor to complete before 
continuing
-            asyncControl.waitForResult(5_000, TimeUnit.MILLISECONDS);
-            assertTrue(asyncControl.getFuture().isDone()); // verify execution 
finished
+            wrapperFactory.asyncWrap(counter, asyncControl)
+                .thenApplyAsync(Counter::increment)
+                .orTimeout(5_000, TimeUnit.MILLISECONDS)
+                .join(); // let's wait max 5 sec to allow executor to complete 
before continuing
         });
 
         // then
@@ -104,15 +102,17 @@ void async_using_default_executor_service(final String 
displayName, final Execut
             var counter = bookmarkService.lookup(bookmark, 
Counter.class).orElseThrow();
             assertThat(counter.getNum()).isEqualTo(1L);
 
-            var asyncControl = AsyncControl.returning(Counter.class)
+            var asyncControl = AsyncControl.defaults()
                     .with(executorService);
 
             // when
-            wrapperFactory.asyncWrapMixin(Counter_bumpUsingMixin.class, 
counter, asyncControl).act();
+            wrapperFactory.asyncWrapMixin(Counter_bumpUsingMixin.class, 
counter, asyncControl)
+                .thenApplyAsync(Counter_bumpUsingMixin::act)
+                // let's wait max 5 sec to allow executor to complete before 
continuing
+                .orTimeout(5_000, TimeUnit.MILLISECONDS)
+                .join(); // wait till done
 
-            // let's wait max 5 sec to allow executor to complete before 
continuing
-            asyncControl.waitForResult(5_000, TimeUnit.MILLISECONDS);
-            assertTrue(asyncControl.getFuture().isDone()); // verify execution 
finished
+            assertThat(counter.getNum()).isEqualTo(2L); // verify execution 
succeeded
         });
 
         // then
diff --git 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
index 3887ffacd97..516def8849b 100644
--- 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
+++ 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/CommandArgumentTest.java
@@ -18,7 +18,6 @@
  */
 package org.apache.causeway.testdomain.interact;
 
-import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
@@ -105,7 +104,7 @@ void listParam_shouldAllowInvocation() {
 
         var pendingArgs = actionInteraction.startParameterNegotiation().get();
 
-        pendingArgs.setParamValue(0, objectManager.adapt(Arrays.asList(1L, 2L, 
3L)));
+        pendingArgs.setParamValue(0, objectManager.adapt(List.of(1L, 2L, 3L)));
 
         var resultOrVeto = actionInteraction.invokeWith(pendingArgs);
         assertTrue(resultOrVeto.isSuccess());
@@ -123,12 +122,13 @@ void listParam_shouldAllowAsyncInvocation() throws 
InterruptedException, Executi
 
         var commandArgDemo = new CommandArgDemo();
 
-        var control = AsyncControl.returning(CommandResult.class);
+        var control = AsyncControl.defaults();
 
-        wrapperFactory.asyncWrap(commandArgDemo, control)
-        .list(Arrays.asList(1L, 2L, 3L));
-
-        var stringified = control.future().get(3L, 
TimeUnit.DAYS).getResultAsString();
+        var stringified = wrapperFactory.asyncWrap(commandArgDemo, control)
+            .thenApplyAsync(commandResult->commandResult.list(List.of(1L, 2L, 
3L)))
+            .orTimeout(3L, TimeUnit.DAYS)
+            .join() // wait till done
+            .getResultAsString();
 
         assertEquals("[1, 2, 3]", stringified);
     }
diff --git 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/WrapperInteraction_Caching_IntegTest.java
 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/WrapperInteraction_Caching_IntegTest.java
index c8763437802..53d1141fdc9 100644
--- 
a/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/WrapperInteraction_Caching_IntegTest.java
+++ 
b/regressiontests/interact/src/test/java/org/apache/causeway/testdomain/interact/WrapperInteraction_Caching_IntegTest.java
@@ -19,7 +19,6 @@
 package org.apache.causeway.testdomain.interact;
 
 import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
@@ -118,22 +117,19 @@ void sync_mixin() throws ExecutionException, 
InterruptedException, TimeoutExcept
     void async_wrapped() throws ExecutionException, InterruptedException, 
TimeoutException {
 
         // when
-        AsyncControl<Integer> asyncControlForCalculator1 = 
AsyncControl.returning(Integer.class);
-        StatefulCalculator asyncCalculator1 = 
wrapperFactory.asyncWrap(calculator1, asyncControlForCalculator1);
+        var asyncControlForCalculator1 = AsyncControl.defaults();
+        var asyncCalculator1 = wrapperFactory.asyncWrap(calculator1, 
asyncControlForCalculator1)
+                .thenApplyAsync(calc->calc.inc(12));
 
-        AsyncControl<Integer> asyncControlForCalculator2 = 
AsyncControl.returning(Integer.class);
-        StatefulCalculator asyncCalculator2 = 
wrapperFactory.asyncWrap(calculator2, asyncControlForCalculator2);
-
-        asyncCalculator1.inc(12);
-        asyncCalculator2.inc(24);
+        var asyncControlForCalculator2 = AsyncControl.defaults();
+        var asyncCalculator2 = wrapperFactory.asyncWrap(calculator2, 
asyncControlForCalculator2)
+                .thenApplyAsync(calc->calc.inc(24));
 
         // then
-        Future<Integer> future = asyncControlForCalculator1.getFuture();
-        Integer i = future.get(10, TimeUnit.SECONDS);
-        Assertions.assertThat(i.intValue()).isEqualTo(12);
+        Assertions.assertThat(asyncCalculator1.orTimeout(10, 
TimeUnit.SECONDS).join().intValue()).isEqualTo(12);
         Assertions.assertThat(calculator1.getTotal()).isEqualTo(12);
 
-        Assertions.assertThat(asyncControlForCalculator2.getFuture().get(10, 
TimeUnit.SECONDS).intValue()).isEqualTo(24);
+        Assertions.assertThat(asyncCalculator2.orTimeout(10, 
TimeUnit.SECONDS).join().intValue()).isEqualTo(24);
         Assertions.assertThat(calculator2.getTotal()).isEqualTo(24);
     }
 


Reply via email to