Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Migrate `entityExists` to C++
## What

Migrate `entityExists` to C+ callback signature.

## ACs

- Removed from Python and implemented in C++ with callback signature.
- apiComplianceSuite updated to callbacks.



Discussion / hints:

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 1d5286bd4..8dff76712 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -18,9 +18,18 @@ v1.0.0-alpha.x
   [1029](https://github.com/OpenAssetIO/OpenAssetIO/issues/1029)
 
 - Removed `Context.Access.kWriteMultiple` and
- `Context.Access.kReadMultiple`access patterns, due to not having
- coherent use cases.
- [1016](https://github.com/OpenAssetIO/OpenAssetIO/issues/1016)
+  `Context.Access.kReadMultiple`access patterns, due to not having
+  coherent use cases.
+  [1016](https://github.com/OpenAssetIO/OpenAssetIO/issues/1016)
+
+- Removed `cpython` dependency from `conanfile.py`. When building
+  OpenAssetIO, it is now expected that a development install of the
+  appropriate Python version is discoverable on the system.
+  [1038](https://github.com/OpenAssetIO/OpenAssetIO/pull/1038)
+
+- Migrated `ManagerInterface`/`Manager` `entityExists` to C++ with
+  Python bindings, and redesigned to use a callback based batch API.
+  [#993](https://github.com/OpenAssetIO/OpenAssetIO/issues/993)
 
 ### New Features
 
@@ -29,13 +38,6 @@ v1.0.0-alpha.x
   existing one.
   [1016](https://github.com/OpenAssetIO/OpenAssetIO/issues/1016)
 
-### Breaking changes
-
-- Removed `cpython` dependency from `conanfile.py`. When building
-  OpenAssetIO, it is now expected that a development install of the
-  appropriate Python version is discoverable on the system.
-  [1038](https://github.com/OpenAssetIO/OpenAssetIO/pull/1038)
-
 ### Improvements
 
 - Increased verbosity when running the `openassetio.test.manager` API
diff --git a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
index e20b4ce7d..d405237bd 100644
--- a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
+++ b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.cpp
@@ -98,6 +98,15 @@ bool CManagerInterfaceAdapter::isEntityReferenceString(
   throw std::runtime_error{"Not implemented"};
 }
 
+void CManagerInterfaceAdapter::entityExists(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::ExistsSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw std::runtime_error{"Not implemented"};
+}
+
 void CManagerInterfaceAdapter::resolve(
     [[maybe_unused]] const EntityReferences& entityReferences,
     [[maybe_unused]] const trait::TraitSet& traitSet,
diff --git a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.hpp b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.hpp
index 6d535aff9..777d6949a 100644
--- a/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.hpp
+++ b/src/openassetio-core-c/src/managerApi/CManagerInterfaceAdapter.hpp
@@ -60,6 +60,13 @@ class OPENASSETIO_CORE_C_EXPORT CManagerInterfaceAdapter : ManagerInterface {
   [[nodiscard]] bool isEntityReferenceString(const Str& someString,
                                              const HostSessionPtr& hostSession) const override;
 
+  /// Wrap the C suite's `entityExists` function.
+  /// @todo Implement C API. Currently throws `runtime_error`.
+  void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
+                    const HostSessionPtr& hostSession,
+                    const ExistsSuccessCallback& successCallback,
+                    const BatchElementErrorCallback& errorCallback) override;
+
   /// Wrap the C suite's `resolve` function.
   /// @todo Implement C API. Currently throws `runtime_error`.
   void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index c6de711a9..b105350a3 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -369,6 +369,64 @@ class OPENASSETIO_CORE_EXPORT Manager {
    */
   ContextPtr contextFromPersistenceToken(const Str& token);
 
+  /**
+   * @}
+   */
+
+  /**
+   * @name Batch element error handling
+   *
+   * @{
+   */
+  /**
+   * Tag dispatching structure intended for use selecting appropriate
+   * overloads for various error-handling modes.
+   *
+   * Many OpenAssetIO functions provide options as to whether errors are
+   * handled via throwing exceptions, or by returning a variant based
+   * result object.
+   */
+  struct BatchElementErrorPolicyTag {
+    /**
+     * Variant policy overloads, when used in a batch context, will be
+     * exhaustive for all elements in the batch, a variant result
+     * containing either a @ref TraitsData or @ref BatchElementError
+     * will be provided for each @ref EntityReference provided as an
+     * argument to the operation.
+     */
+    struct Variant {};
+    /**
+     * Exception policy overloads, when used in a batch context, will emit
+     * an exception at the first encountered @ref BatchElementError
+     * provided by the @ref ManagerInterface. This exception may not be in
+     * index order.
+     */
+    struct Exception {};
+
+    /**
+     * Static instantiation of the @ref Variant dispatch tag, to avoid
+     * the need to construct a new object to resolve dispatch methods.
+     */
+    static constexpr Variant kVariant{};
+    /**
+     * Static instantiation of the @ref Exception dispatch tag, to avoid
+     * the need to construct a new object to resolve dispatch methods.
+     */
+    static constexpr Exception kException{};
+  };
+
+  /**
+   * Callback signature used for an unsuccessful operation on an
+   * element in a batch.
+   *
+   * This will be called for errors that are specific to a particular
+   * reference in a batch. Exceptions can be thrown to indicate a
+   * whole-batch error.
+   *
+   * The appropriate error code should be used for these errors. See
+   * @fqref{BatchElementError.ErrorCode} "ErrorCode".
+   */
+  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
   /**
    * @}
    */
@@ -419,12 +477,8 @@ class OPENASSETIO_CORE_EXPORT Manager {
    * format of the string is recognised. The call is notionally trivial
    * and does not involve back-end system queries.
    *
-   * @see @needsref entityExists
+   * @see @ref entityExists
    * @see @ref resolve
-   *
-   * @todo Make use of
-   * openassetio.constants.kInfoKey_EntityReferencesMatchPrefix if
-   * supplied, especially when bridging between C/python.
    */
   [[nodiscard]] bool isEntityReferenceString(const Str& someString) const;
 
@@ -467,63 +521,55 @@ class OPENASSETIO_CORE_EXPORT Manager {
       Str entityReferenceString) const;
 
   /**
-   * @}
+   * Callback signature used for a successful entity existence query.
    */
+  using ExistsSuccessCallback = std::function<void(std::size_t, bool)>;
 
   /**
-   * @name Batch element error handling
+   * Called to determine if each @ref entity_reference supplied
+   * points to an entity that exists in the @ref
+   * asset_management_system, and that they can be resolved into
+   * a meaningful string or otherwise queried.
+   *
+   * By 'exist' we mean 'is ready to be read'. For example,
+   * entityExists may be called before attempting to read from a
+   * reference that is believed to point to an image sequence, so
+   * that alternatives can be found.
+   *
+   * In the future, this may need to be extended to cover a more
+   * complex definition of 'existence' (for example, known to the
+   * system, but not yet finalized). For now however, it should be
+   * assumed to simply mean, 'ready to be consumed', and if only a
+   * placeholder or un-finalized asset is available, `False` should
+   * be returned.
+   *
+   * The supplied context's locale should be well-configured as it
+   * may contain information pertinent to disambiguating this subtle
+   * definition of 'exists' in some cases too, as it better explains
+   * the use-case of the call.
    *
-   * @{
-   */
-  /**
-   * Tag dispatching structure intended for use selecting appropriate
-   * overloads for various error-handling modes.
+   * @param entityReferences Entity references to query.
    *
-   * Many OpenAssetIO functions provide options as to whether errors are
-   * handled via throwing exceptions, or by returning a variant based
-   * result object.
-   */
-  struct BatchElementErrorPolicyTag {
-    /**
-     * Variant policy overloads, when used in a batch context, will be
-     * exhaustive for all elements in the batch, a variant result
-     * containing either a @ref TraitsData or @ref BatchElementError
-     * will be provided for each @ref EntityReference provided as an
-     * argument to the operation.
-     */
-    struct Variant {};
-    /**
-     * Exception policy overloads, when used in a batch context, will emit
-     * an exception at the first encountered @ref BatchElementError
-     * provided by the @ref ManagerInterface. This exception may not be in
-     * index order.
-     */
-    struct Exception {};
-
-    /**
-     * Static instantiation of the @ref Variant dispatch tag, to avoid
-     * the need to construct a new object to resolve dispatch methods.
-     */
-    static constexpr Variant kVariant{};
-    /**
-     * Static instantiation of the @ref Exception dispatch tag, to avoid
-     * the need to construct a new object to resolve dispatch methods.
-     */
-    static constexpr Exception kException{};
-  };
-
-  /**
-   * Callback signature used for an unsuccessful operation on an
-   * element in a batch.
+   * @param context The calling context.
    *
-   * This will be called for errors that are specific to a particular
-   * reference in a batch. Exceptions can be thrown to indicate a
-   * whole-batch error.
+   * @param successCallback Callback that will be called for each
+   * successful check of an entity reference. It will be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with a boolean indicating existence, as defined above. The
+   * callback will be called on the same thread that initiated the call
+   * to `resolve`.
    *
-   * The appropriate error code should be used for these errors. See
-   * @fqref{BatchElementError.ErrorCode} "ErrorCode".
+   * @param errorCallback Callback that will be called for each
+   * failed check of an entity reference. It will be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with a populated @fqref{BatchElementError}
+   * "BatchElementError" (see @fqref{BatchElementError.ErrorCode}
+   * "ErrorCodes"). The callback will be called on the same thread
+   * that initiated the call to `resolve`.
    */
-  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
+  void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
+                    const ExistsSuccessCallback& successCallback,
+                    const BatchElementErrorCallback& errorCallback);
   /**
    * @}
    */
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index c867e789d..0c360bf2c 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -534,6 +534,27 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @}
    */
 
+  /**
+   * @name Batch element error handling
+   *
+   * @{
+   */
+  /**
+   * Callback signature used for an unsuccessful operation on an
+   * element in a batch.
+   *
+   * This should be called for errors that are specific to a particular
+   * reference in a batch. Exceptions can be thrown to indicate a
+   * whole-batch error.
+   *
+   * @see @fqref{BatchElementError.ErrorCode} "ErrorCode" for
+   * appropriate error codes.
+   */
+  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
+  /**
+   * @}
+   */
+
   /**
    * @name Entity Reference inspection
    *
@@ -584,33 +605,67 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * format of the string is recognised as a potential entity reference
    * by the manager.
    *
-   * @see @needsref entityExists
+   * @see @ref entityExists
    * @see @ref resolve
    */
   [[nodiscard]] virtual bool isEntityReferenceString(const Str& someString,
                                                      const HostSessionPtr& hostSession) const = 0;
 
   /**
-   * @}
+   * Callback signature used for a successful entity existence query.
    */
+  using ExistsSuccessCallback = std::function<void(std::size_t, bool)>;
 
   /**
-   * @name Batch element error handling
+   * Called to determine if each @ref entity_reference supplied
+   * points to an entity that exists in the @ref
+   * asset_management_system, and that they can be resolved into
+   * a meaningful string or otherwise queried.
+   *
+   * By 'exist' we mean 'is ready to be read'. For example,
+   * entityExists may be called before attempting to read from a
+   * reference that is believed to point to an image sequence, so
+   * that alternatives can be found.
+   *
+   * In the future, this may need to be extended to cover a more
+   * complex definition of 'existence' (for example, known to the
+   * system, but not yet finalized). For now however, it should be
+   * assumed to simply mean, 'ready to be consumed', and if only a
+   * placeholder or un-finalized asset is available, `False` should
+   * be returned.
+   *
+   * The supplied context's locale may contain information pertinent
+   * to disambiguating this subtle definition of 'exists' in some
+   * cases too, as it better explains the use-case of the call.
    *
-   * @{
-   */
-  /**
-   * Callback signature used for an unsuccessful operation on an
-   * element in a batch.
+   * @param entityReferences Entity references to query.
    *
-   * This should be called for errors that are specific to a particular
-   * reference in a batch. Exceptions can be thrown to indicate a
-   * whole-batch error.
+   * @param context The calling context.
+   *
+   * @param hostSession The host session that maps to the caller, this
+   * should be used for all logging and provides access to the Host
+   * object representing the process that initiated the API session.
+   *
+   * @param successCallback Callback that must be called for each
+   * successful check of an entity reference. It should be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with a boolean indicating the existence (as defined above) of
+   * the entity. The callback must be called on the same thread that
+   * initiated the call to `entityExists`.
    *
-   * The appropriate error code should be used for these errors. See
-   * @fqref{BatchElementError.ErrorCode} "ErrorCode".
+   * @param errorCallback Callback that must be called for each
+   * failed check of an entity reference. It should be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with a populated @fqref{BatchElementError}
+   * "BatchElementError" (see @fqref{BatchElementError.ErrorCode}
+   * "ErrorCodes"). The callback must be called on the same thread
+   * that initiated the call to `entityExists`.
    */
-  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
+  virtual void entityExists(const EntityReferences& entityReferences,
+                            const ContextConstPtr& context, const HostSessionPtr& hostSession,
+                            const ExistsSuccessCallback& successCallback,
+                            const BatchElementErrorCallback& errorCallback) = 0;
+
   /**
    * @}
    */
@@ -700,9 +755,8 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * "ErrorCodes"). The callback must be called on the same thread
    * that initiated the call to `resolve`.
    *
-   * @see @needsref entityExists
-   * @see @fqref{managerApi.ManagerInterface.isEntityReferenceString}
-   * "isEntityReferenceString"
+   * @see @ref entityExists
+   * @see @ref isEntityReferenceString
    * @see @fqref{BatchElementError} "BatchElementError"
    */
   virtual void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 3c78bc39d..c7b26e047 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -160,6 +160,14 @@ std::optional<EntityReference> Manager::createEntityReferenceIfValid(
   return EntityReference{std::move(entityReferenceString)};
 }
 
+void Manager::entityExists(const EntityReferences &entityReferences,
+                           const ContextConstPtr &context,
+                           const ExistsSuccessCallback &successCallback,
+                           const BatchElementErrorCallback &errorCallback) {
+  managerInterface_->entityExists(entityReferences, context, hostSession_, successCallback,
+                                  errorCallback);
+}
+
 void Manager::resolve(const EntityReferences &entityReferences, const trait::TraitSet &traitSet,
                       const ContextConstPtr &context,
                       const ResolveSuccessCallback &successCallback,
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
index cef4b802b..f7d4977af 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
@@ -82,6 +82,8 @@ void registerManager(const py::module& mod) {
            py::arg("entityReferenceString"))
       .def("createEntityReferenceIfValid", &Manager::createEntityReferenceIfValid,
            py::arg("entityReferenceString"))
+      .def("entityExists", &Manager::entityExists, py::arg("entityReferences"),
+           py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"))
       .def("resolve",
            static_cast<void (Manager::*)(
                const EntityReferences&, const trait::TraitSet&, const ContextConstPtr&,
diff --git a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
index fb9a557cf..919953c97 100644
--- a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
@@ -88,6 +88,14 @@ struct PyManagerInterface : ManagerInterface {
                            hostSession);
   }
 
+  void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
+                    const HostSessionPtr& hostSession,
+                    const ExistsSuccessCallback& successCallback,
+                    const BatchElementErrorCallback& errorCallback) override {
+    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, entityExists, entityReferences, context,
+                           hostSession, successCallback, errorCallback);
+  }
+
   void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
                const ContextConstPtr& context, const HostSessionPtr& hostSession,
                const ResolveSuccessCallback& successCallback,
@@ -202,6 +210,9 @@ void registerManagerInterface(const py::module& mod) {
            py::arg("token"), py::arg("hostSession").none(false))
       .def("isEntityReferenceString", &ManagerInterface::isEntityReferenceString,
            py::arg("someString"), py::arg("hostSession").none(false))
+      .def("entityExists", &ManagerInterface::entityExists, py::arg("entityReferences"),
+           py::arg("context").none(false), py::arg("hostSession").none(false),
+           py::arg("successCallback"), py::arg("errorCallback"))
       .def("resolve", &ManagerInterface::resolve, py::arg("entityReferences"), py::arg("traitSet"),
            py::arg("context").none(false), py::arg("hostSession").none(false),
            py::arg("successCallback"), py::arg("errorCallback"))
diff --git a/src/openassetio-python/package/openassetio/hostApi/Manager.py b/src/openassetio-python/package/openassetio/hostApi/Manager.py
index 85c569fbb..b9695dfec 100644
--- a/src/openassetio-python/package/openassetio/hostApi/Manager.py
+++ b/src/openassetio-python/package/openassetio/hostApi/Manager.py
@@ -135,52 +135,6 @@ def updateTerminology(self, stringDict):
 
     ## @}
 
-    ##
-    # @name Entity Reference inspection
-    #
-    # @{
-
-    @debugApiCall
-    @auditApiCall("Manager methods")
-    def entityExists(self, entityRefs, context):
-        """
-        Called to determine if each @ref entity_reference supplied
-        points to an entity that exists in the @ref
-        asset_management_system, and that they can be resolved into
-        a meaningful string or otherwise queried.
-
-        By 'exist' we mean 'is ready to be read'. For example,
-        entityExists may be called before attempting to read from a
-        reference that is believed to point to an image sequence, so
-        that alternatives can be found.
-
-        In the future, this may need to be extended to cover a more
-        complex definition of 'existence' (for example, known to the
-        system, but not yet finalized). For now however, it should be
-        assumed to simply mean, 'ready to be consumed', and if only a
-        placeholder or un-finalized asset is available, `False` should
-        be returned.
-
-        The supplied context's locale should be well-configured as it
-        may contain information pertinent to disambiguating this subtle
-        definition of 'exists' in some cases too, as it better explains
-        the use-case of the call.
-
-        @param entityRefs `List[` @fqref{EntityReference}
-        "EntityReference" `]` Entity references to query.
-
-        @param context Context The calling context.
-
-        @return `List[bool]` `True` if the corresponding element in
-        entityRefs points to an existing entity, `False` if the entity
-        is not known or ready yet.
-
-        @unstable
-        """
-        return self.__impl.entityExists(entityRefs, context, self.__hostSession)
-
-    ## @}
-
     ##
     # @name Entity Retrieval
     #
diff --git a/src/openassetio-python/package/openassetio/managerApi/ManagerInterface.py b/src/openassetio-python/package/openassetio/managerApi/ManagerInterface.py
index e73645d05..4e19a4062 100644
--- a/src/openassetio-python/package/openassetio/managerApi/ManagerInterface.py
+++ b/src/openassetio-python/package/openassetio/managerApi/ManagerInterface.py
@@ -192,53 +192,6 @@ def updateTerminology(self, stringDict, hostSession):
 
     ## @}
 
-    ##
-    # @name Entity Reference inspection
-    #
-    #
-    # @{
-
-    @abc.abstractmethod
-    def entityExists(self, entityRefs, context, hostSession):
-        """
-        Called to determine if each @ref entity_reference supplied
-        points to an entity that exists in the @ref
-        asset_management_system, and that they can be resolved into
-        a meaningful string or otherwise queried.
-
-        By 'exist' we mean 'is ready to be read'. For example,
-        entityExists may be called before attempting to read from a
-        reference that is believed to point to an image sequence, so
-        that alternatives can be found.
-
-        In the future, this may need to be extended to cover a more
-        complex definition of 'existence' (for example, known to the
-        system, but not yet finalized). For now however, it should be
-        assumed to simply mean, 'ready to be consumed', and if only a
-        placeholder or un-finalized asset is available, `False` should
-        be returned.
-
-        The supplied context's locale may contain information pertinent
-        to disambiguating this subtle definition of 'exists' in some
-        cases too, as it better explains the use-case of the call.
-
-        @param entityRefs `List[` @fqref{EntityReference}
-        "EntityReference" `]` Entity references to query.
-
-        @param context Context The calling context.
-
-        @param hostSession HostSession The API session.
-
-        @return `List[bool]` `True` if the corresponding element in
-        entityRefs points to an existing entity, `False` if the entity
-        is not known or ready yet.
-
-        @unstable
-        """
-        raise NotImplementedError
-
-    ## @}
-
     ##
     # @name Entity Reference Resolution
     #


Test patch:
diff --git a/src/openassetio-core-c/tests/hostApi/ManagerTest.cpp b/src/openassetio-core-c/tests/hostApi/ManagerTest.cpp
index b1c77d47d..22371d9ea 100644
--- a/src/openassetio-core-c/tests/hostApi/ManagerTest.cpp
+++ b/src/openassetio-core-c/tests/hostApi/ManagerTest.cpp
@@ -46,6 +46,7 @@ struct MockManagerInterface : trompeloeil::mock_interface<managerApi::ManagerInt
   IMPLEMENT_MOCK2(initialize);
   IMPLEMENT_CONST_MOCK3(managementPolicy);
   IMPLEMENT_CONST_MOCK2(isEntityReferenceString);
+  IMPLEMENT_MOCK5(entityExists);
   IMPLEMENT_MOCK6(resolve);
   IMPLEMENT_MOCK6(preflight);
   IMPLEMENT_MOCK6(register_);  // NOLINT(readability-identifier-naming)
diff --git a/src/openassetio-core/tests/hostApi/ManagerTest.cpp b/src/openassetio-core/tests/hostApi/ManagerTest.cpp
index 69a8d1484..07d7f26a8 100644
--- a/src/openassetio-core/tests/hostApi/ManagerTest.cpp
+++ b/src/openassetio-core/tests/hostApi/ManagerTest.cpp
@@ -31,6 +31,7 @@ struct MockManagerInterface : trompeloeil::mock_interface<managerApi::ManagerInt
   IMPLEMENT_MOCK2(initialize);
   IMPLEMENT_CONST_MOCK3(managementPolicy);
   IMPLEMENT_CONST_MOCK2(isEntityReferenceString);
+  IMPLEMENT_MOCK5(entityExists);
   IMPLEMENT_MOCK6(resolve);
   IMPLEMENT_MOCK6(preflight);
   IMPLEMENT_MOCK6(register_);  // NOLINT(readability-identifier-naming)
diff --git a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
index 7df836ee5..7b56969fa 100644
--- a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
+++ b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
@@ -248,30 +248,66 @@ class Test_entityExists(FixtureAugmentedTestCase):
     """
 
     def setUp(self):
-        self.a_reference_to_an_existing_entity = self._manager.createEntityReference(
-            self.requireFixture("a_reference_to_an_existing_entity", skipTestIfMissing=True)
+        self.a_reference_to_an_existing_entity = self.requireEntityReferenceFixture(
+            "a_reference_to_an_existing_entity", skipTestIfMissing=True
         )
-        self.a_reference_to_a_nonexisting_entity = self._manager.createEntityReference(
-            self.requireFixture("a_reference_to_a_nonexisting_entity")
+
+    def test_when_querying_existing_reference_then_true_is_returned(self):
+        context = self.createTestContext()
+        result = [None]
+        self._manager.entityExists(
+            [self.a_reference_to_an_existing_entity],
+            context,
+            lambda idx, value: operator.setitem(result, idx, value),
+            lambda idx, error: self.fail(f"entityExists should not fail: {error.message}"),
         )
+        self.assertEqual(result, [True])
 
-    def test_existing_reference_returns_true(self):
+    def test_when_querying_nonexisting_reference_then_false_is_returned(self):
+        a_reference_to_a_nonexisting_entity = self.requireEntityReferenceFixture(
+            "a_reference_to_a_nonexisting_entity"
+        )
         context = self.createTestContext()
-        assert self._manager.entityExists([self.a_reference_to_an_existing_entity], context) == [
-            True
-        ]
+        result = [None]
+        self._manager.entityExists(
+            [a_reference_to_a_nonexisting_entity],
+            context,
+            lambda idx, value: operator.setitem(result, idx, value),
+            lambda idx, error: self.fail(f"entityExists should not fail: {error.message}"),
+        )
+        self.assertEqual(result, [False])
 
-    def test_non_existant_reference_returns_false(self):
+    def test_when_querying_existing_and_nonexisting_references_then_true_and_false_is_returned(
+        self,
+    ):
+        a_reference_to_a_nonexisting_entity = self.requireEntityReferenceFixture(
+            "a_reference_to_a_nonexisting_entity"
+        )
         context = self.createTestContext()
-        assert self._manager.entityExists([self.a_reference_to_a_nonexisting_entity], context) == [
-            False
-        ]
+        result = [None, None]
+        self._manager.entityExists(
+            [self.a_reference_to_an_existing_entity, a_reference_to_a_nonexisting_entity],
+            context,
+            lambda idx, value: operator.setitem(result, idx, value),
+            lambda idx, error: self.fail(f"entityExists should not fail: {error.message}"),
+        )
+        self.assertEqual(result, [True, False])
 
-    def test_mixed_inputs_returns_mixed_output(self):
-        existing = self.a_reference_to_an_existing_entity
-        nonexistant = self.a_reference_to_a_nonexisting_entity
+    def test_when_querying_malformed_reference_then_malformed_reference_error_is_returned(self):
+        a_malformed_reference = self.requireEntityReferenceFixture("a_malformed_reference")
+        expected_error_message = self.requireFixture("expected_error_message")
         context = self.createTestContext()
-        assert self._manager.entityExists([existing, nonexistant], context) == [True, False]
+        result = [None]
+        self._manager.entityExists(
+            [a_malformed_reference],
+            context,
+            lambda idx, value: self.fail("entityExists should not succeed"),
+            lambda idx, error: operator.setitem(result, idx, error),
+        )
+        [error] = result
+
+        self.assertEqual(error.code, BatchElementError.ErrorCode.kMalformedEntityReference)
+        self.assertEqual(error.message, expected_error_message)
 
 
 class Test_resolve(FixtureAugmentedTestCase):
diff --git a/src/openassetio-python/tests/conftest.py b/src/openassetio-python/tests/conftest.py
index ee47a6234..659c7ae8d 100644
--- a/src/openassetio-python/tests/conftest.py
+++ b/src/openassetio-python/tests/conftest.py
@@ -216,10 +216,14 @@ def isEntityReferenceString(self, someString, hostSession):
         assert isinstance(hostSession, HostSession)
         return self.mock.isEntityReferenceString(someString, hostSession)
 
-    def entityExists(self, entityRefs, context, hostSession):
+    def entityExists(self, entityRefs, context, hostSession, successCallback, errorCallback):
         self.__assertIsIterableOf(entityRefs, EntityReference)
         self.__assertCallingContext(context, hostSession)
-        return self.mock.entityExists(entityRefs, context, hostSession)
+        assert callable(successCallback)
+        assert callable(errorCallback)
+        return self.mock.entityExists(
+            entityRefs, context, hostSession, successCallback, errorCallback
+        )
 
     def resolve(self, entityRefs, traitSet, context, hostSession, successCallback, errorCallback):
         self.__assertIsIterableOf(entityRefs, EntityReference)
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index 479de7197..63d2280cf 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -17,8 +17,8 @@
 Tests that cover the openassetio.hostApi.Manager wrapper class.
 """
 
-# pylint: disable=no-self-use
-# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=invalid-name,redefined-outer-name,unused-argument
+# pylint: disable=too-many-lines,too-many-locals
 # pylint: disable=missing-class-docstring,missing-function-docstring
 from unittest import mock
 
@@ -351,16 +351,38 @@ def test_when_valid_then_returns_configured_EntityReference(
 
 
 class Test_Manager_entityExists:
-    def test_method_defined_in_python(self, method_introspector):
-        assert method_introspector.is_defined_in_python(Manager.entityExists)
+    def test_method_defined_in_cpp(self, method_introspector):
+        assert not method_introspector.is_defined_in_python(Manager.entityExists)
         assert method_introspector.is_implemented_once(Manager, "entityExists")
 
     def test_wraps_the_corresponding_method_of_the_held_interface(
-        self, manager, mock_manager_interface, a_host_session, some_refs, a_context
+        self,
+        manager,
+        mock_manager_interface,
+        a_host_session,
+        some_refs,
+        a_context,
+        a_batch_element_error,
+        invoke_entityExists_success_cb,
+        invoke_entityExists_error_cb,
     ):
+        success_callback = mock.Mock()
+        error_callback = mock.Mock()
+
         method = mock_manager_interface.mock.entityExists
-        assert manager.entityExists(some_refs, a_context) == method.return_value
-        method.assert_called_once_with(some_refs, a_context, a_host_session)
+
+        def call_callbacks(*_args):
+            invoke_entityExists_success_cb(123, False)
+            invoke_entityExists_error_cb(456, a_batch_element_error)
+
+        method.side_effect = call_callbacks
+
+        manager.entityExists(some_refs, a_context, success_callback, error_callback)
+
+        method.assert_called_once_with(some_refs, a_context, a_host_session, mock.ANY, mock.ANY)
+
+        success_callback.assert_called_once_with(123, False)
+        error_callback.assert_called_once_with(456, a_batch_element_error)
 
 
 class Test_Manager_defaultEntityReference:
@@ -3081,6 +3103,24 @@ def some_different_refs(manager):
     return [manager.createEntityReference("asset://e"), manager.createEntityReference("asset://f")]
 
 
+@pytest.fixture
+def invoke_entityExists_success_cb(mock_manager_interface):
+    def invoke(idx, exists):
+        callback = mock_manager_interface.mock.entityExists.call_args[0][3]
+        callback(idx, exists)
+
+    return invoke
+
+
+@pytest.fixture
+def invoke_entityExists_error_cb(mock_manager_interface):
+    def invoke(idx, batch_element_error):
+        callback = mock_manager_interface.mock.entityExists.call_args[0][4]
+        callback(idx, batch_element_error)
+
+    return invoke
+
+
 @pytest.fixture
 def invoke_getWithRelationshipPaged_success_cb(mock_manager_interface):
     def invoke(idx, entityReferencesPagerInterface):