Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Update `BatchElementException` hierarchy
## What

Update the `BatchElementException` hierarchy to include additional useful information for hosts, including an intermediate base class allowing entity reference -specific errors to be filtered.

## Why

The current `BatchElementException`s currently include the element index as a data member, but simply regurgitate the underlying `BatchElementError` message. Given the location where the exception is caught could be far from the call site, additional information in the error object and message would be valuable to the host.

## ACs

* All `BatchElementError` and `BatchElementException` types moved under an `errors` namespace.
* ~~Aliases added in place of previous location, with `[[deprecated]]` annotation (and equivalent for Python, if possible).~~ Decided to allow breaking changes based on [TSC]()
* `BatchElementException` updated to inherit from (recently added) `OpenAssetIOException`
* All `BatchElementException` subclasses removed.
* `BatchElementException` constructor takes the index, the BEE and an optional `suffix` / `additionalIInfo` (or similar) string parameter.  
* The exception message:
  - should be all on one line, for nicer concurrency (flush-on-newline) and greppability
  - should begin with the `BatchElementError` type name (i.e. translate `code` to string)
  - if either the `BatchElementError.message` or `suffix` is provided, then follow with a `: `
  - then should include the `BatchElementError.message`, if non-blank 
  - then should include the suffix, if provided.
* `BatchElementException`-throwing methods updated to construct a `suffix` argument from available relevant information eg:
  - Entity reference as `[<entity reference>]`
  - Access mode as `[access=<mode>]`
  E.g. 
  - `"InvalidPreflightHint: manager-provided error [index=2][access=read][my://entity/ref]"`
  - `"EntityAccessError: [index=2][access=write][my://entity/ref]"`
  - `"Unknown: manager-specific error [index=2]"`
* `CODING_STANDARDS` updated with guidelines for the order of extra data
* log an issue for any future message enhancements

```[tasklist]
### Tasks
- [ ] Implement the feature
- [ ] Re-enable intregrations
```

Update `BatchElementException` hierarchy
## What

Update the `BatchElementException` hierarchy to include additional useful information for hosts, including an intermediate base class allowing entity reference -specific errors to be filtered.

## Why

The current `BatchElementException`s currently include the element index as a data member, but simply regurgitate the underlying `BatchElementError` message. Given the location where the exception is caught could be far from the call site, additional information in the error object and message would be valuable to the host.

## ACs

* All `BatchElementError` and `BatchElementException` types moved under an `errors` namespace.
* ~~Aliases added in place of previous location, with `[[deprecated]]` annotation (and equivalent for Python, if possible).~~ Decided to allow breaking changes based on [TSC]()
* `BatchElementException` updated to inherit from (recently added) `OpenAssetIOException`
* All `BatchElementException` subclasses removed.
* `BatchElementException` constructor takes the index, the BEE and an optional `suffix` / `additionalIInfo` (or similar) string parameter.  
* The exception message:
  - should be all on one line, for nicer concurrency (flush-on-newline) and greppability
  - should begin with the `BatchElementError` type name (i.e. translate `code` to string)
  - if either the `BatchElementError.message` or `suffix` is provided, then follow with a `: `
  - then should include the `BatchElementError.message`, if non-blank 
  - then should include the suffix, if provided.
* `BatchElementException`-throwing methods updated to construct a `suffix` argument from available relevant information eg:
  - Entity reference as `[<entity reference>]`
  - Access mode as `[access=<mode>]`
  E.g. 
  - `"InvalidPreflightHint: manager-provided error [index=2][access=read][my://entity/ref]"`
  - `"EntityAccessError: [index=2][access=write][my://entity/ref]"`
  - `"Unknown: manager-specific error [index=2]"`
* `CODING_STANDARDS` updated with guidelines for the order of extra data
* log an issue for any future message enhancements

```[tasklist]
### Tasks
- [ ] Implement the feature
- [ ] Re-enable intregrations
```



Discussion / hints:
EDIT: this message related to the now-abandoned ACs - see comment below.

A couple of changes to the original plan:

* All exception data members are `std::optional`, since we cannot prevent the manager from providing a `BatchElementError` with an inappropriate code for the API method.  We decided to go with a best-effort approach - filling in the details we have available.
* We decided against including the trait set/data in the error message, at least for now, to prevent unwieldy long messages or additional work figuring out appropriate truncation.
Playing devils advocate - if we're making them optional, and not using some of them in the message, is there any point putting them in at this juncture? YAGNI?
IIRC when we discussed it the argument was to provide users with all the information they could need, if not in the exception message then at least in the data structure.  I'd be happy to strip out everything that's not used for the exception message, though.
Totally up to you - just asking the annoying questions :)
ACs updated based on offline chat. For posterity:


## Abandoned ACs

The following ACs were abandoned due to the growing cost:value ratio of implementing and maintaining this structure.

* New intemediate base added
  ```c++
  struct BatchElementEntityReferenceException : BatchElementException {
    EntityReference entityReference;
  };
  ```
* `InvalidEntityReferenceBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* `MalformedEntityReferenceBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* `EntityAccessErrorBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference **and** stringified access modifier.
* `EntityResolutionErrorBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* New exception type added
  ```c++
  struct InvalidTraitsDataBatchElementException : BatchElementException {
    std::optional<EntityReference> entityReference
    TraitsDataPtr traitsData;
  };
  ```
  - constructor suffixes the wrapped `BatchElementError`'s message with provided entity reference, if set, and the (truncated) `traitsData`.
* `InvalidPreflightHintBatchElementException` updated to inherit from `InvalidTraitsDataBatchElementException`
* `InvalitTraitSetBatchElementException` updated to
  ```c++
  struct InvalitTraitSetBatchElementException : BatchElementException {
    std::optional<EntityReference> entityReference
    TraitSet traitSet;
  };
  ```
  - constructor suffixes the wrapped `BatchElementError`'s message with provided entity reference, if set, and the `traitSet`

EDIT: this message related to the now-abandoned ACs - see comment below.

A couple of changes to the original plan:

* All exception data members are `std::optional`, since we cannot prevent the manager from providing a `BatchElementError` with an inappropriate code for the API method.  We decided to go with a best-effort approach - filling in the details we have available.
* We decided against including the trait set/data in the error message, at least for now, to prevent unwieldy long messages or additional work figuring out appropriate truncation.
Playing devils advocate - if we're making them optional, and not using some of them in the message, is there any point putting them in at this juncture? YAGNI?
IIRC when we discussed it the argument was to provide users with all the information they could need, if not in the exception message then at least in the data structure.  I'd be happy to strip out everything that's not used for the exception message, though.
Totally up to you - just asking the annoying questions :)
ACs updated based on offline chat. For posterity:


## Abandoned ACs

The following ACs were abandoned due to the growing cost:value ratio of implementing and maintaining this structure.

* New intemediate base added
  ```c++
  struct BatchElementEntityReferenceException : BatchElementException {
    EntityReference entityReference;
  };
  ```
* `InvalidEntityReferenceBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* `MalformedEntityReferenceBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* `EntityAccessErrorBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference **and** stringified access modifier.
* `EntityResolutionErrorBatchElementException` updated to inherit from `BatchElementEntityReferenceException` and suffix the wrapped `BatchElementError`'s message with a provided entity reference.
* New exception type added
  ```c++
  struct InvalidTraitsDataBatchElementException : BatchElementException {
    std::optional<EntityReference> entityReference
    TraitsDataPtr traitsData;
  };
  ```
  - constructor suffixes the wrapped `BatchElementError`'s message with provided entity reference, if set, and the (truncated) `traitsData`.
* `InvalidPreflightHintBatchElementException` updated to inherit from `InvalidTraitsDataBatchElementException`
* `InvalitTraitSetBatchElementException` updated to
  ```c++
  struct InvalitTraitSetBatchElementException : BatchElementException {
    std::optional<EntityReference> entityReference
    TraitSet traitSet;
  };
  ```
  - constructor suffixes the wrapped `BatchElementError`'s message with provided entity reference, if set, and the `traitSet`


Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9e63488b5..9eac40aa5 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -10,6 +10,11 @@ v1.0.0-alpha.xx
   share a common base.
   [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
 
+- Exceptional conveniences now provide additional information such
+  as entityReference, index, access, and error type in the message
+  of the exception.
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
+
 ### Breaking changes
 
 - Attempting to retrieve a trait property with
@@ -27,12 +32,12 @@ v1.0.0-alpha.xx
   implementations to make use of private state for efficiency reasons.
   [#518](https://github.com/OpenAssetIO/OpenAssetIO/issues/518)
 
-- Moved `BatchElementError.hpp` into `errors` directory.
-  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
-
 - Removed python `exceptions` module in favour of new `errors` module.
   [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
 
+- `errors.h` renamed to `errorCodes.h`
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
 - Changed success/return type of `defaultEntityReference` to wrap in
   `std::optional`. Updated expected manager response such that an
   optional without a value signals that no default is available for an
@@ -40,6 +45,16 @@ v1.0.0-alpha.xx
   an empty optional in the success callback, rather than error.
   [#1100](https://github.com/OpenAssetIO/OpenAssetIO/issues/1100)
 
+ - Moved `BatchElementError` and `BatchElementException` into the
+  `errors` namespace.
+  [#1071](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1071)
+
+  - Removed all subtypes of `BatchElementException`, in favour of a
+  single exception type for exceptional type for batch errors in
+  exceptional conveniences.
+  [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
+
 ### Improvements
 
 - Improved error messaging when `defaultManagerForInterface` points to a
diff --git a/doc/doxygen/src/Glossary.dox b/doc/doxygen/src/Glossary.dox
index 2f064b959..f449a9d92 100644
--- a/doc/doxygen/src/Glossary.dox
+++ b/doc/doxygen/src/Glossary.dox
@@ -410,9 +410,9 @@
  * Providing the preflight hint allows the manager to prepare the
  * backend and provide the most appropriate responses to subsequent @ref
  * glossary_resolve and @ref glossary_register requests; or
- * @fqref{BatchElementError.ErrorCode.kInvalidPreflightHint} "error"
- * early if publishing is likely to fail or insufficient information is
- * given in the hint.
+ * @fqref{errors.BatchElementError.ErrorCode.kInvalidPreflightHint}
+ * "error" early if publishing is likely to fail or insufficient
+ * information is given in the hint.
  *
  * The `preflight` method is also allowed to change the entity
  * reference as required. This mechanism serves several purposes:
diff --git a/src/openassetio-core-c/include/openassetio/c/errors.h b/src/openassetio-core-c/include/openassetio/c/errors.h
index 2d20be3d3..e2c32370e 100644
--- a/src/openassetio-core-c/include/openassetio/c/errors.h
+++ b/src/openassetio-core-c/include/openassetio/c/errors.h
@@ -2,7 +2,7 @@
 // Copyright 2013-2022 The Foundry Visionmongers Ltd
 #pragma once
 
-#include <openassetio/errors/errors.h>
+#include <openassetio/errors/errorCodes.h>
 
 #include "./namespace.h"
 
diff --git a/src/openassetio-core/CMakeLists.txt b/src/openassetio-core/CMakeLists.txt
index 65a0637b2..ede00dedf 100644
--- a/src/openassetio-core/CMakeLists.txt
+++ b/src/openassetio-core/CMakeLists.txt
@@ -66,6 +66,7 @@ target_sources(
     PRIVATE
     src/Context.cpp
     src/TraitsData.cpp
+    src/errors/exceptionMessages.cpp
     src/hostApi/HostInterface.cpp
     src/hostApi/Manager.cpp
     src/hostApi/ManagerFactory.cpp
diff --git a/src/openassetio-core/include/openassetio/EntityReference.hpp b/src/openassetio-core/include/openassetio/EntityReference.hpp
index 67b8696ea..0d5347413 100644
--- a/src/openassetio-core/include/openassetio/EntityReference.hpp
+++ b/src/openassetio-core/include/openassetio/EntityReference.hpp
@@ -29,7 +29,7 @@ inline namespace OPENASSETIO_CORE_ABI_VERSION {
  *
  * Note that this does not preclude the possibility of a malformed
  * reference. See
- * @fqref{BatchElementError.ErrorCode.kInvalidEntityReference}
+ * @fqref{errors.BatchElementError.ErrorCode.kInvalidEntityReference}
  * "kInvalidEntityReference".
  */
 class EntityReference final {
diff --git a/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp b/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
index 262bb157a..a5b7194a4 100644
--- a/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
+++ b/src/openassetio-core/include/openassetio/errors/BatchElementError.hpp
@@ -7,12 +7,13 @@
 #include <string>
 #include <utility>
 
-#include <openassetio/errors/errors.h>
+#include <openassetio/errors/errorCodes.h>
 #include <openassetio/export.h>
 #include <openassetio/typedefs.hpp>
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace errors {
 /**
  * @name Batch element errors
  *
@@ -152,88 +153,9 @@ class BatchElementError final {
 static_assert(std::is_move_constructible_v<BatchElementError>);
 static_assert(std::is_move_assignable_v<BatchElementError>);
 
-/**
- * Exception base that ties together a @ref BatchElementError and an
- * index.
- *
- * When thrown from a function, indicates that a particular
- * element has caused an error. The specific element that has errored
- * is indicated by the index attribute, relative to the input container.
- */
-struct OPENASSETIO_CORE_EXPORT BatchElementException : std::runtime_error {
-  BatchElementException(std::size_t idx, BatchElementError err)
-      : std::runtime_error{err.message}, index{idx}, error{std::move(err)} {}
-
-  /**
-   * Index describing which batch element has caused an error.
-   */
-  std::size_t index;
-
-  /**
-   * Object describing the nature of the specific error.
-   */
-  BatchElementError error;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kUnknown
- */
-struct OPENASSETIO_CORE_EXPORT UnknownBatchElementException : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kInvalidEntityReference
- */
-struct OPENASSETIO_CORE_EXPORT InvalidEntityReferenceBatchElementException
-    : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kMalformedEntityReference
- */
-struct OPENASSETIO_CORE_EXPORT MalformedEntityReferenceBatchElementException
-    : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kEntityAccessError
- */
-struct OPENASSETIO_CORE_EXPORT EntityAccessErrorBatchElementException : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kEntityResolutionError
- */
-struct OPENASSETIO_CORE_EXPORT EntityResolutionErrorBatchElementException : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kInvalidPreflightHint
- */
-struct OPENASSETIO_CORE_EXPORT InvalidPreflightHintBatchElementException : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
-
-/**
- * Exception equivalent of
- * @ref BatchElementError.ErrorCode.kInvalidTraitSet
- */
-struct OPENASSETIO_CORE_EXPORT InvalidTraitSetBatchElementException : BatchElementException {
-  using BatchElementException::BatchElementException;
-};
 /**
  * @}
  */
+}  // namespace errors
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio
diff --git a/src/openassetio-core/include/openassetio/errors/errors.h b/src/openassetio-core/include/openassetio/errors/errorCodes.h
similarity index 100%
rename from src/openassetio-core/include/openassetio/errors/errors.h
rename to src/openassetio-core/include/openassetio/errors/errorCodes.h
diff --git a/src/openassetio-core/include/openassetio/errors/exceptions.hpp b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
index 225549410..d5cee4581 100644
--- a/src/openassetio-core/include/openassetio/errors/exceptions.hpp
+++ b/src/openassetio-core/include/openassetio/errors/exceptions.hpp
@@ -4,16 +4,36 @@
 
 #include <stdexcept>
 #include <string>
+#include <utility>
 
 #include <openassetio/export.h>
+#include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/typedefs.hpp>
 
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
+/**
+ * This namespace contains types related to error handling.
+ *
+ * Generally there are two types of error handling, "standard" non-batch
+ * error handling, which is exception based, and batch errors, which are
+ * based upon the @ref BatchElementError type.
+ *
+ * All exceptions in OpenAssetIO are derived from the @ref
+ * OpenAssetIOException type, the idea being a host can use this as a
+ * catch-all exception type when attempting mitigative exception
+ * handling.
+ *
+ * Batch error handling with @ref BatchElementError is not
+ * exceptional, However, OpenAssetIO provides convenience wrappers
+ * around some batch functions that makes them exceptional, therefore
+ * @ref BatchElementError is converted to its twin,
+ * @ref BatchElementException, also found in this namespace.
+ */
 namespace errors {
+
 /**
  * @name OpenassetIO Exceptions
- *
  * @{
  */
 
@@ -65,6 +85,28 @@ struct OPENASSETIO_CORE_EXPORT UnhandledException : OpenAssetIOException {
   using OpenAssetIOException::OpenAssetIOException;
 };
 
+/**
+ * Exception thrown when a @ref BatchElementError is converted.
+ *
+ * Not a type that a manager should throw, exclusively thrown via the
+ * middleware when the user is calling an exceptional convenience and a
+ * @ref BatchElementError is emitted by the manager.
+ */
+struct OPENASSETIO_CORE_EXPORT BatchElementException : OpenAssetIOException {
+  BatchElementException(std::size_t idx, BatchElementError err, const std::string& message)
+      : OpenAssetIOException{message}, index{idx}, error{std::move(err)} {}
+
+  /**
+   * Index describing which batch element has caused an error.
+   */
+  std::size_t index;
+
+  /**
+   * Object describing the nature of the specific error.
+   */
+  BatchElementError error;
+};
+
 /**
  * @}
  */
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index 992cf3d11..fb7d9f0e7 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -419,16 +419,16 @@ class OPENASSETIO_CORE_EXPORT Manager final {
     /**
      * 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.
+     * containing either a @ref TraitsData or @ref
+     * errors.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.
+     * Exception policy overloads, when used in a batch context, will
+     * emit an exception at the first encountered @ref
+     * errors.BatchElementError provided by the @ref ManagerInterface.
+     * This exception may not be in index order.
      */
     struct Exception {};
 
@@ -453,9 +453,9 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * whole-batch error.
    *
    * The appropriate error code should be used for these errors. See
-   * @fqref{BatchElementError.ErrorCode} "ErrorCode".
+   * @fqref{errors.BatchElementError.ErrorCode} "ErrorCode".
    */
-  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
+  using BatchElementErrorCallback = std::function<void(std::size_t, errors::BatchElementError)>;
   /**
    * @}
    */
@@ -591,8 +591,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `entityExists`.
    */
@@ -655,14 +655,14 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *  Consult the originating project of the trait for more information.
    *
    * There may be errors during resolution. These can either be
-   * exceptions thrown from `resolve`, or @fqref{BatchElementError}
-   * "BatchElementError"s given to the `errorCallback`. Exceptions are
-   * unexpected errors that fail the whole batch. `BatchElementError`s
-   * are errors that are specific to a particular entity - other
-   * entities may still resolve successfully. Using HTTP status codes as
-   * an analogy, typically a server error (5xx) would correspond to an
-   * exception whereas a client error (4xx) would correspond to a
-   * `BatchElementError`.
+   * exceptions thrown from `resolve`, or
+   * @fqref{errors.BatchElementError} "BatchElementError"s given to the
+   * `errorCallback`. Exceptions are unexpected errors that fail the
+   * whole batch. `BatchElementError`s are errors that are specific to a
+   * particular entity - other entities may still resolve successfully.
+   * Using HTTP status codes as an analogy, typically a server error
+   * (5xx) would correspond to an exception whereas a client error (4xx)
+   * would correspond to a `BatchElementError`.
    *
    * This call will block until all resolutions are complete and
    * callbacks have been called. Callbacks will be called on the
@@ -687,8 +687,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each
    * failed resolution 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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `resolve`.
    */
@@ -711,7 +711,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * Errors that occur during resolution will be thrown as an exception,
    * either from the @ref manager plugin (for errors not specific to the
-   * entity reference) or as a @fqref{BatchElementException}
+   * entity reference) or as a @fqref{errors.BatchElementException}
    * "BatchElementException"-derived error.
    *
    * @param entityReference Entity reference to query.
@@ -736,7 +736,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
 
   /**
    * Provides either a populated @fqref{TraitsData} "TraitsData" or a
-   * @fqref{BatchElementError} "BatchElementError".
+   * @fqref{errors.BatchElementError} "BatchElementError".
    *
    * If successful, the result is populated with the
    * available data for the requested set of traits for the given @ref
@@ -772,7 +772,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @return Object containing either the populated data or an error
    * object.
    */
-  std::variant<BatchElementError, TraitsDataPtr> resolve(
+  std::variant<errors::BatchElementError, TraitsDataPtr> resolve(
       const EntityReference& entityReference, const trait::TraitSet& traitSet,
       access::ResolveAccess resolveAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
@@ -792,8 +792,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * Any errors that occur during resolution will be immediately thrown
    * as an exception, either from the @ref manager plugin (for errors
    * not specific to the entity reference) or as a
-   * @fqref{BatchElementException} "BatchElementException"-derived
-   * error.
+   * @fqref{errors.BatchElementException}
+   * "BatchElementException"-derived error.
    *
    * @param entityReferences Entity references to query.
    *
@@ -818,8 +818,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
 
   /**
    * Provides either a populated @fqref{TraitsData} "TraitsData" or a
-   * @fqref{BatchElementError} "BatchElementError" for each given @ref
-   * entity_reference.
+   * @fqref{errors.BatchElementError} "BatchElementError" for each given
+   * @ref entity_reference.
    *
    * For successful references, the corresponding element of the result
    * is populated with the available data for the requested set of
@@ -856,7 +856,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @return List of objects, each containing either the populated data
    * or an error.
    */
-  std::vector<std::variant<BatchElementError, TraitsDataPtr>> resolve(
+  std::vector<std::variant<errors::BatchElementError, TraitsDataPtr>> resolve(
       const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
       access::ResolveAccess resolveAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
@@ -899,9 +899,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each failure
    * to retrieve a sensible default entity reference. It will be given
    * the corresponding index for each of the given sets in @p traitSets
-   * along with a populated @fqref{BatchElementError}
-   * "BatchElementError" (see @fqref{BatchElementError.ErrorCode}
-   * "ErrorCodes"). The @fqref{BatchElementError.ErrorCode.kInvalidTraitSet}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
+   * "ErrorCodes"). The
+   * @fqref{errors.BatchElementError.ErrorCode.kEntityAccessError}
+   * "kEntityAccessError" error will be used if no suitable default
+   * reference exists, and the
+   * @fqref{errors.BatchElementError.ErrorCode.kInvalidTraitSet}
    * "kInvalidTraitSet" error will be used if the requested trait set is
    * not recognised by the manager. The callback will be called on the
    * same thread that initiated the call to `defaultEntityReference`.
@@ -1000,7 +1004,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each failed
    * relationship query. It will be given the corresponding index of the
    * entity reference in @p entityReferences along with a populated
-   * BatchElementError (see @ref BatchElementError.ErrorCode
+   * BatchElementError (see @ref errors.BatchElementError.ErrorCode
    * "ErrorCodes"). The callback will be called on the same thread that
    * initiated the call to `getWithRelationship`.
    *
@@ -1056,7 +1060,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each failed
    * relationship query. It will be given the corresponding index of the
    * relationship in @p relationshipTraitsDatas along with a populated
-   * BatchElementError (see @fqref{BatchElementError.ErrorCode}
+   * BatchElementError (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread that
    * initiated the call to `getWithRelationships`.
    *
@@ -1115,7 +1119,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each failed
    * relationship query. It will be given the corresponding index of the
    * entity reference in @p entityReferences along with a populated
-   * BatchElementError (see @ref BatchElementError.ErrorCode
+   * BatchElementError (see @ref errors.BatchElementError.ErrorCode
    * "ErrorCodes"). The callback will be called on the same thread that
    * initiated the call to `getWithRelationshipPaged`.
    *
@@ -1165,7 +1169,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each failed
    * relationship query. It will be given the corresponding index of the
    * relationship in @p relationshipTraitsDatas along with a populated
-   * BatchElementError (see @fqref{BatchElementError.ErrorCode}
+   * BatchElementError (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread that
    * initiated the call to `getWithRelationshipsPaged`.
    *
@@ -1235,8 +1239,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * traits, please see @ref entities_traits_and_specifications "this"
    * page.
    *
-   * The action of 'publishing' itself, is split into two parts, depending on
-   * the nature of the item to be published.
+   * The action of 'publishing' itself, is split into two parts,
+   * depending on the nature of the item to be published.
    *
    *  @li **Preflight** When you are about to create some new
    *  media/asset.
@@ -1329,8 +1333,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each
    * failed preflight 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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `preflight`.
    *
@@ -1356,8 +1360,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * Any errors that occur during the preflight call will be immediately
    * thrown as an exception, either from the @ref manager plugin (for
    * errors not specific to the entity reference) or as a
-   * @fqref{BatchElementException} "BatchElementException"-derived
-   * error.
+   * @fqref{errors.BatchElementException}
+   * "BatchElementException"-derived error.
    *
    * @param entityReference The entity reference to preflight prior
    * to registration.
@@ -1428,7 +1432,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * object detailing the reason for the failure of this particular
    * entity.
    */
-  std::variant<BatchElementError, EntityReference> preflight(
+  std::variant<errors::BatchElementError, EntityReference> preflight(
       const EntityReference& entityReference, const TraitsDataPtr& traitsHint,
       access::PublishingAccess publishingAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
@@ -1448,8 +1452,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * Any errors that occur during the preflight call will be immediately
    * thrown as an exception, either from the @ref manager plugin (for
    * errors not specific to an entity reference) or as a
-   * @fqref{BatchElementException} "BatchElementException"-derived
-   * error.
+   * @fqref{errors.BatchElementException}
+   * "BatchElementException"-derived error.
    *
    * @param entityReferences The entity references to preflight prior
    * to registration.
@@ -1524,7 +1528,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * an error object detailing the reason for the failure of that
    * particular entity.
    */
-  std::vector<std::variant<BatchElementError, EntityReference>> preflight(
+  std::vector<std::variant<errors::BatchElementError, EntityReference>> preflight(
       const EntityReferences& entityReferences, const trait::TraitsDatas& traitsHints,
       access::PublishingAccess publishingAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
@@ -1612,8 +1616,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param errorCallback Callback that will be called for each
    * failed registration 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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `register`.
    *
@@ -1650,8 +1654,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * Any errors that occur during the register_ call will be immediately
    * thrown as an exception, either from the @ref manager plugin (for
    * errors not specific to the entity reference) or as a
-   * @fqref{BatchElementException} "BatchElementException"-derived
-   * error.
+   * @fqref{errors.BatchElementException}
+   * "BatchElementException"-derived error.
    *
    * @param entityReference Entity reference to register to.
    *
@@ -1721,7 +1725,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * the failure of this particular entity.
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
-  std::variant<BatchElementError, EntityReference> register_(
+  std::variant<errors::BatchElementError, EntityReference> register_(
       const EntityReference& entityReference, const TraitsDataPtr& entityTraitsData,
       access::PublishingAccess publishingAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
@@ -1741,8 +1745,8 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * Any errors that occur during the register_ call will be immediately
    * thrown as an exception, either from the @ref manager plugin (for
    * errors not specific to the entity reference) or as a
-   * @fqref{BatchElementException} "BatchElementException"-derived
-   * error.
+   * @fqref{errors.BatchElementException}
+   * "BatchElementException"-derived error.
    *
    * @param entityReferences Entity references to register to.
    *
@@ -1818,7 +1822,7 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * particular entity.
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
-  std::vector<std::variant<BatchElementError, EntityReference>> register_(
+  std::vector<std::variant<errors::BatchElementError, EntityReference>> register_(
       const EntityReferences& entityReferences, const trait::TraitsDatas& entityTraitsDatas,
       access::PublishingAccess publishingAccess, const ContextConstPtr& context,
       const BatchElementErrorPolicyTag::Variant& errorPolicyTag);
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index 43472cb61..9b1d97f5f 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -577,10 +577,10 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * reference in a batch. Exceptions can be thrown to indicate a
    * whole-batch error.
    *
-   * @see @fqref{BatchElementError.ErrorCode} "ErrorCode" for
+   * @see @fqref{errors.BatchElementError.ErrorCode} "ErrorCode" for
    * appropriate error codes.
    */
-  using BatchElementErrorCallback = std::function<void(std::size_t, BatchElementError)>;
+  using BatchElementErrorCallback = std::function<void(std::size_t, errors::BatchElementError)>;
   /**
    * @}
    */
@@ -686,8 +686,8 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback must be called on the same thread
    * that initiated the call to `entityExists`.
    */
@@ -756,7 +756,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * thrown for unexpected errors that should fail the whole batch, and
    * it is up to the host to handle the exception. For errors specific
    * to a particular entity, where other entities may still resolve
-   * successfully, an appropriate @fqref{BatchElementError}
+   * successfully, an appropriate @fqref{errors.BatchElementError}
    * "BatchElementError" should be given to the @p errorCallback. Using
    * HTTP status codes as an analogy, typically a server error (5xx)
    * would correspond to an exception whereas a client error (4xx) would
@@ -782,14 +782,14 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @param errorCallback Callback that must be called for each
    * failed resolution 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}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback must be called on the same thread
    * that initiated the call to `resolve`.
    *
    * @see @ref entityExists
    * @see @ref isEntityReferenceString
-   * @see @fqref{BatchElementError} "BatchElementError"
+   * @see @fqref{errors.BatchElementError} "BatchElementError"
    */
   virtual void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
                        access::ResolveAccess resolveAccess, const ContextConstPtr& context,
@@ -821,7 +821,8 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * wish to return this so that a 'Create Shots' action starts
    * somewhere meaningful.
    *
-   * The default implementation returns a @fqref{BatchElementError.ErrorCode.kEntityAccessError}
+   * The default implementation returns a
+   * @fqref{errors.BatchElementError.ErrorCode.kEntityAccessError}
    * "kEntityAccessError" indicating that no valid default exists.
    *
    * @param traitSets The relevant trait sets for the type of entities
@@ -849,9 +850,13 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @param errorCallback Callback that must be called for each failure
    * to retrieve a sensible default entity reference. It should be given
    * the corresponding index for each of the given sets in @p traitSets
-   * along with a populated @fqref{BatchElementError}
-   * "BatchElementError" (see @fqref{BatchElementError.ErrorCode}
-   * "ErrorCodes"). The @fqref{BatchElementError.ErrorCode.kInvalidTraitSet}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
+   * "ErrorCodes"). The
+   * @fqref{errors.BatchElementError.ErrorCode.kEntityAccessError}
+   * "kEntityAccessError" error must be used if no suitable default
+   * reference exists, and the
+   * @fqref{errors.BatchElementError.ErrorCode.kInvalidTraitSet}
    * "kInvalidTraitSet" error should be used if the requested trait set
    * is unrecognised. The callback must be called on the same thread
    * that initiated the call to `defaultEntityReference`.
@@ -959,10 +964,10 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * failed relationship query for 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
-   * `getWithRelationship`.
+   * @fqref{errors.BatchElementError} "BatchElementError" (see
+   * @fqref{errors.BatchElementError.ErrorCode} "ErrorCodes"). The
+   * callback must be called on the same thread that initiated the call
+   * to `getWithRelationship`.
    *
    * @note Ensure that your implementation of @ref managementPolicy
    * responds appropriately when queried with relationship trait sets
@@ -1094,7 +1099,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @param errorCallback Callback that should be called for each failed
    * relationship query. It should be given the corresponding index of
    * the entity reference in @p entityReferences along with a populated
-   * BatchElementError (see @ref BatchElementError.ErrorCode
+   * BatchElementError (see @ref errors.BatchElementError.ErrorCode
    * "ErrorCodes"). The callback should be called on the same thread
    * that initiated the call to `getWithRelationship`.
    *
@@ -1153,9 +1158,9 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * relationship query. It should be given the corresponding index of
    * the relationship in @p relationshipTraitsDatas along with a
    * populated BatchElementError (see
-   * @fqref{BatchElementError.ErrorCode} "ErrorCodes"). The callback
-   * should be called on the same thread that initiated the call to
-   * `getWithRelationships`.
+   * @fqref{errors.BatchElementError.ErrorCode} "ErrorCodes"). The
+   * callback should be called on the same thread that initiated the
+   * call to `getWithRelationships`.
    *
    * @param resultTraitSet A hint as to what traits the returned
    * entities should have.
@@ -1278,7 +1283,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * insufficient or invalid for upcoming @ref resolve for
    * @fqref{access.ResolveAccess.kWrite} "write" requests or the
    * eventual @ref register_, then error that element with an
-   * appropriate @fqref{BatchElementError.ErrorCode}.
+   * appropriate @fqref{errors.BatchElementError.ErrorCode}.
    *
    * @param entityReferences An @ref entity_reference for each entity
    * that it is desired to publish the forthcoming data to. See the
@@ -1315,18 +1320,18 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @param errorCallback Callback to be called for each failed
    * preflight of an entity reference. It should be called with the
    * corresponding index of the entity reference in @p entityReferences
-   * along with a populated @fqref{BatchElementError}
-   * "BatchElementError" (see @fqref{BatchElementError.ErrorCode}
+   * along with a populated @fqref{errors.BatchElementError}
+   * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback must be called on the same thread that
    * initiated the call to `preflight`. A
-   * @fqref{BatchElementError.ErrorCode.kEntityAccessError}
+   * @fqref{errors.BatchElementError.ErrorCode.kEntityAccessError}
    * "kEntityAccessError" should be used if the access pattern cannot be
    * adhered to, for example when attempting to write any target
    * references that are conceptually read-only in response to
    * @fqref{access.PublishAccess.kWrite} "kWrite", or in response to
    * @fqref{access.PublishAccess.kCreateRelated} "kCreateRelated" if
    * creating related entities is not supported. A
-   * @fqref{BatchElementError.ErrorCode.kInvalidPreflightHint}
+   * @fqref{errors.BatchElementError.ErrorCode.kInvalidPreflightHint}
    * "kInvalidPreflightHint" should be used for any target references
    * who's corresponding @p traitsHints entry holds insufficient or
    * invalid information.
@@ -1376,8 +1381,9 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    *
    * If the supplied @ref trait_set is missing required traits for any
    * of the provided references (maybe they are mismatched with the
-   * target entity, or missing essential data) then error that
-   * element with an appropriate @fqref{BatchElementError.ErrorCode} "ErrorCode".
+   * target entity, or missing essential data) then error that element
+   * with an appropriate @fqref{errors.BatchElementError.ErrorCode}
+   * "ErrorCode".
    *
    * @param entityReferences  The @ref entity_reference of each entity
    * to register_. It is up to the manager to ensure that this is
@@ -1424,12 +1430,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * call to `register`.
    *
    * @param errorCallback Callback to be called for each failed
-   * registration. It should be called with 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
-   * `register`.
+   * registration. It should be called with the corresponding index of
+   * the entity reference in @p entityReferences along with a populated
+   * @fqref{errors.BatchElementError} "BatchElementError" (see
+   * @fqref{errors.BatchElementError.ErrorCode} "ErrorCodes"). The
+   * callback must be called on the same thread that initiated the call
+   * to `register`.
    *
    * @see @fqref{TraitsData} "TraitsData"
    * @see @ref preflight
diff --git a/src/openassetio-core/src/errors/exceptionMessages.cpp b/src/openassetio-core/src/errors/exceptionMessages.cpp
new file mode 100644
index 000000000..6168bc685
--- /dev/null
+++ b/src/openassetio-core/src/errors/exceptionMessages.cpp
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+
+#include "exceptionMessages.hpp"
+
+#include <cassert>
+
+#include <fmt/core.h>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace errors {
+
+Str errorCodeName(BatchElementError::ErrorCode code) {
+  switch (code) {
+    case BatchElementError::ErrorCode::kUnknown:
+      return "unknown";
+    case BatchElementError::ErrorCode::kInvalidEntityReference:
+      return "invalidEntityReference";
+    case BatchElementError::ErrorCode::kMalformedEntityReference:
+      return "malformedEntityReference";
+    case BatchElementError::ErrorCode::kEntityAccessError:
+      return "entityAccessError";
+    case BatchElementError::ErrorCode::kEntityResolutionError:
+      return "entityResolutionError";
+    case BatchElementError::ErrorCode::kInvalidPreflightHint:
+      return "invalidPreflightHint";
+    case BatchElementError::ErrorCode::kInvalidTraitSet:
+      return "invalidTraitSet";
+  }
+
+  assert(false);  // Impossible case.
+  return "Unknown ErrorCode";
+}
+
+std::string createBatchElementExceptionMessage(const BatchElementError& err, size_t index,
+                                               const EntityReference& entityReference,
+                                               access::Access access) {
+  /*
+   * BatchElementException messages consist of five parts.
+   * 1. The name of the error code.
+   * 2. The message inside the BatchElementError.
+   * 3. The index that the batch error relates to.
+   * 4. The access mode.
+   * 5. The entity reference.
+   *
+   * Ends up looking something like : "entityAccessError: Could not
+   * access Entity [index=2] [access=read] [entity=bal:///entityRef]"
+   */
+  const auto errorCodeStr = fmt::format("{}:", errorCodeName(err.code));
+  const auto errorMessageStr = err.message.empty() ? "" : fmt::format(" {}", err.message);
+
+  // Data elements
+  const auto indexStr = fmt::format(" [index={}]", index);
+  const auto accessStr = fmt::format(" [access={}]", access::kAccessNames[access]);
+  const auto entityReferenceStr = fmt::format(" [entity={}]", entityReference.toString());
+
+  return fmt::format("{}{}{}{}{}", errorCodeStr, errorMessageStr, indexStr, accessStr,
+                     entityReferenceStr);
+}
+}  // namespace errors
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-core/src/errors/exceptionMessages.hpp b/src/openassetio-core/src/errors/exceptionMessages.hpp
new file mode 100644
index 000000000..bc2fdf937
--- /dev/null
+++ b/src/openassetio-core/src/errors/exceptionMessages.hpp
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2023 The Foundry Visionmongers Ltd
+#pragma once
+
+#include <optional>
+#include <string>
+
+#include <openassetio/EntityReference.hpp>
+#include <openassetio/access.hpp>
+#include <openassetio/errors/BatchElementError.hpp>
+#include <openassetio/typedefs.hpp>
+
+namespace openassetio {
+inline namespace OPENASSETIO_CORE_ABI_VERSION {
+namespace errors {
+/**Get an error code name as a printable string*/
+Str errorCodeName(BatchElementError::ErrorCode code);
+
+/**Construct a full message to place into a convenience exception.*/
+std::string createBatchElementExceptionMessage(const BatchElementError& err, size_t index,
+                                               const EntityReference& entityReference,
+                                               access::Access access);
+}  // namespace errors
+}  // namespace OPENASSETIO_CORE_ABI_VERSION
+}  // namespace openassetio
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 8ac666fa3..90855901e 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -16,36 +16,11 @@
 #include <openassetio/managerApi/ManagerInterface.hpp>
 #include <openassetio/typedefs.hpp>
 
+#include "../errors/exceptionMessages.hpp"
+
 namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
 namespace {
-// Takes a BatchElementError and throws an equivalent exception.
-void throwFromBatchElementError(std::size_t index, BatchElementError error) {
-  switch (error.code) {
-    case BatchElementError::ErrorCode::kUnknown:
-      throw UnknownBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kInvalidEntityReference:
-      throw InvalidEntityReferenceBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kMalformedEntityReference:
-      throw MalformedEntityReferenceBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kEntityAccessError:
-      throw EntityAccessErrorBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kEntityResolutionError:
-      throw EntityResolutionErrorBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kInvalidPreflightHint:
-      throw InvalidPreflightHintBatchElementException(index, std::move(error));
-    case BatchElementError::ErrorCode::kInvalidTraitSet:
-      throw InvalidTraitSetBatchElementException(index, std::move(error));
-    default:
-      std::string exceptionMessage = "Invalid BatchElementError. Code: ";
-      exceptionMessage += std::to_string(static_cast<int>(error.code));
-      exceptionMessage += " Message: ";
-      exceptionMessage += error.message;
-      error.message = std::move(exceptionMessage);
-      throw UnknownBatchElementException{index, std::move(error)};
-  }
-}
-
 /**
  * Extract the entity reference prefix from a manager plugin's info
  * dictionary, if available.
@@ -197,25 +172,27 @@ TraitsDataPtr hostApi::Manager::resolve(
       [&resolveResult]([[maybe_unused]] std::size_t index, TraitsDataPtr data) {
         resolveResult = std::move(data);
       },
-      [](std::size_t index, BatchElementError error) {
-        throwFromBatchElementError(index, std::move(error));
+      [&entityReference, resolveAccess](std::size_t index, errors::BatchElementError error) {
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReference, static_cast<access::Access>(resolveAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return resolveResult;
 }
 
 // Singular variant
-std::variant<BatchElementError, TraitsDataPtr> hostApi::Manager::resolve(
+std::variant<errors::BatchElementError, TraitsDataPtr> hostApi::Manager::resolve(
     const EntityReference &entityReference, const trait::TraitSet &traitSet,
     const access::ResolveAccess resolveAccess, const ContextConstPtr &context,
     [[maybe_unused]] const BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::variant<BatchElementError, TraitsDataPtr> resolveResult;
+  std::variant<errors::BatchElementError, TraitsDataPtr> resolveResult;
   resolve(
       {entityReference}, traitSet, resolveAccess, context,
       [&resolveResult]([[maybe_unused]] std::size_t index, TraitsDataPtr data) {
         resolveResult = std::move(data);
       },
-      [&resolveResult]([[maybe_unused]] std::size_t index, BatchElementError error) {
+      [&resolveResult]([[maybe_unused]] std::size_t index, errors::BatchElementError error) {
         resolveResult = std::move(error);
       });
 
@@ -235,27 +212,29 @@ std::vector<TraitsDataPtr> hostApi::Manager::resolve(
       [&resolveResult](std::size_t index, TraitsDataPtr data) {
         resolveResult[index] = std::move(data);
       },
-      [](std::size_t index, BatchElementError error) {
+      [&entityReferences, resolveAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
-        throwFromBatchElementError(index, std::move(error));
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReferences[index], static_cast<access::Access>(resolveAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return resolveResult;
 }
 
 // Multi variant
-std::vector<std::variant<BatchElementError, TraitsDataPtr>> hostApi::Manager::resolve(
+std::vector<std::variant<errors::BatchElementError, TraitsDataPtr>> hostApi::Manager::resolve(
     const EntityReferences &entityReferences, const trait::TraitSet &traitSet,
     const access::ResolveAccess resolveAccess, const ContextConstPtr &context,
     [[maybe_unused]] const BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::vector<std::variant<BatchElementError, TraitsDataPtr>> resolveResult;
+  std::vector<std::variant<errors::BatchElementError, TraitsDataPtr>> resolveResult;
   resolveResult.resize(entityReferences.size());
   resolve(
       entityReferences, traitSet, resolveAccess, context,
       [&resolveResult](std::size_t index, TraitsDataPtr data) {
         resolveResult[index] = std::move(data);
       },
-      [&resolveResult](std::size_t index, BatchElementError error) {
+      [&resolveResult](std::size_t index, errors::BatchElementError error) {
         resolveResult[index] = std::move(error);
       });
 
@@ -377,24 +356,26 @@ EntityReference Manager::preflight(
       [&result]([[maybe_unused]] std::size_t index, EntityReference preflightedRef) {
         result = std::move(preflightedRef);
       },
-      [](std::size_t index, BatchElementError error) {
-        throwFromBatchElementError(index, std::move(error));
+      [&entityReference, publishingAccess](std::size_t index, errors::BatchElementError error) {
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReference, static_cast<access::Access>(publishingAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return result;
 }
 
-std::variant<BatchElementError, EntityReference> Manager::preflight(
+std::variant<errors::BatchElementError, EntityReference> Manager::preflight(
     const EntityReference &entityReference, const TraitsDataPtr &traitsHint,
     const access::PublishingAccess publishingAccess, const ContextConstPtr &context,
     [[maybe_unused]] const Manager::BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::variant<BatchElementError, EntityReference> result;
+  std::variant<errors::BatchElementError, EntityReference> result;
   preflight(
       {entityReference}, {traitsHint}, publishingAccess, context,
       [&result]([[maybe_unused]] std::size_t index, EntityReference preflightedRef) {
         result = std::move(preflightedRef);
       },
-      [&result]([[maybe_unused]] std::size_t index, BatchElementError error) {
+      [&result]([[maybe_unused]] std::size_t index, errors::BatchElementError error) {
         result = std::move(error);
       });
 
@@ -413,26 +394,28 @@ EntityReferences Manager::preflight(
       [&results](std::size_t index, EntityReference preflightedRef) {
         results[index] = std::move(preflightedRef);
       },
-      [](std::size_t index, BatchElementError error) {
+      [&entityReferences, publishingAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
-        throwFromBatchElementError(index, std::move(error));
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReferences[index], static_cast<access::Access>(publishingAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return results;
 }
 
-std::vector<std::variant<BatchElementError, EntityReference>> Manager::preflight(
+std::vector<std::variant<errors::BatchElementError, EntityReference>> Manager::preflight(
     const EntityReferences &entityReferences, const trait::TraitsDatas &traitsHints,
     const access::PublishingAccess publishingAccess, const ContextConstPtr &context,
     [[maybe_unused]] const Manager::BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::vector<std::variant<BatchElementError, EntityReference>> results;
+  std::vector<std::variant<errors::BatchElementError, EntityReference>> results;
   results.resize(entityReferences.size());
   preflight(
       entityReferences, traitsHints, publishingAccess, context,
       [&results](std::size_t index, EntityReference entityReference) {
         results[index] = std::move(entityReference);
       },
-      [&results](std::size_t index, BatchElementError error) {
+      [&results](std::size_t index, errors::BatchElementError error) {
         results[index] = std::move(error);
       });
 
@@ -468,25 +451,27 @@ EntityReference hostApi::Manager::register_(
       [&result]([[maybe_unused]] std::size_t index, EntityReference registeredRef) {
         result = std::move(registeredRef);
       },
-      [](std::size_t index, BatchElementError error) {
-        throwFromBatchElementError(index, std::move(error));
+      [&entityReference, publishingAccess](std::size_t index, errors::BatchElementError error) {
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReference, static_cast<access::Access>(publishingAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return result;
 }
 
 // Singular variant
-std::variant<BatchElementError, EntityReference> hostApi::Manager::register_(
+std::variant<errors::BatchElementError, EntityReference> hostApi::Manager::register_(
     const EntityReference &entityReference, const TraitsDataPtr &entityTraitsData,
     const access::PublishingAccess publishingAccess, const ContextConstPtr &context,
     [[maybe_unused]] const BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::variant<BatchElementError, EntityReference> result;
+  std::variant<errors::BatchElementError, EntityReference> result;
   register_(
       {entityReference}, {entityTraitsData}, publishingAccess, context,
       [&result]([[maybe_unused]] std::size_t index, EntityReference registeredRef) {
         result = std::move(registeredRef);
       },
-      [&result]([[maybe_unused]] std::size_t index, BatchElementError error) {
+      [&result]([[maybe_unused]] std::size_t index, errors::BatchElementError error) {
         result = std::move(error);
       });
 
@@ -506,27 +491,31 @@ std::vector<EntityReference> hostApi::Manager::register_(
       [&result](std::size_t index, EntityReference registeredRef) {
         result[index] = std::move(registeredRef);
       },
-      [](std::size_t index, BatchElementError error) {
+      [&entityReferences, publishingAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
-        throwFromBatchElementError(index, std::move(error));
+        auto msg = errors::createBatchElementExceptionMessage(
+            error, index, entityReferences[index], static_cast<access::Access>(publishingAccess));
+        throw errors::BatchElementException(index, std::move(error), msg);
       });
 
   return result;
 }
 
 // Multi variant
-std::vector<std::variant<BatchElementError, EntityReference>> hostApi::Manager::register_(
+std::vector<std::variant<errors::BatchElementError, EntityReference>> hostApi::Manager::register_(
     const EntityReferences &entityReferences, const trait::TraitsDatas &entityTraitsDatas,
     const access::PublishingAccess publishingAccess, const ContextConstPtr &context,
     [[maybe_unused]] const BatchElementErrorPolicyTag::Variant &errorPolicyTag) {
-  std::vector<std::variant<BatchElementError, EntityReference>> result;
+  std::vector<std::variant<errors::BatchElementError, EntityReference>> result;
   result.resize(entityReferences.size());
   register_(
       entityReferences, entityTraitsDatas, publishingAccess, context,
       [&result](std::size_t index, EntityReference registeredRef) {
         result[index] = std::move(registeredRef);
       },
-      [&result](std::size_t index, BatchElementError error) { result[index] = std::move(error); });
+      [&result](std::size_t index, errors::BatchElementError error) {
+        result[index] = std::move(error);
+      });
 
   return result;
 }
diff --git a/src/openassetio-python/cmodule/CMakeLists.txt b/src/openassetio-python/cmodule/CMakeLists.txt
index e0e884aa4..592689b07 100644
--- a/src/openassetio-python/cmodule/CMakeLists.txt
+++ b/src/openassetio-python/cmodule/CMakeLists.txt
@@ -43,11 +43,11 @@ target_sources(
     src/_openassetio.cpp
     src/accessBinding.cpp
     src/constantsBinding.cpp
-    src/errorsBinding.cpp
-    src/BatchElementErrorBinding.cpp
     src/ContextBinding.cpp
     src/EntityReferenceBinding.cpp
     src/TraitsDataBinding.cpp
+    src/errors/exceptionsBinding.cpp
+    src/errors/BatchElementErrorBinding.cpp
     src/hostApi/EntityReferencePagerBinding.cpp
     src/hostApi/ManagerBinding.cpp
     src/hostApi/HostInterfaceBinding.cpp
diff --git a/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp b/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp
deleted file mode 100644
index abb855eb8..000000000
--- a/src/openassetio-python/cmodule/src/BatchElementErrorBinding.cpp
+++ /dev/null
@@ -1,132 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-// Copyright 2013-2022 The Foundry Visionmongers Ltd
-#include <string_view>
-
-#include <pybind11/eval.h>
-#include <pybind11/operators.h>
-#include <pybind11/pybind11.h>
-
-#include <openassetio/errors/BatchElementError.hpp>
-
-#include "_openassetio.hpp"
-
-void registerBatchElementError(py::module &mod) {
-  using openassetio::BatchElementError;
-
-  py::class_<BatchElementError> batchElementError{mod, "BatchElementError", py::is_final()};
-
-  py::enum_<BatchElementError::ErrorCode>{batchElementError, "ErrorCode"}
-      .value("kUnknown", BatchElementError::ErrorCode::kUnknown)
-      .value("kInvalidEntityReference", BatchElementError::ErrorCode::kInvalidEntityReference)
-      .value("kMalformedEntityReference", BatchElementError::ErrorCode::kMalformedEntityReference)
-      .value("kEntityAccessError", BatchElementError::ErrorCode::kEntityAccessError)
-      .value("kEntityResolutionError", BatchElementError::ErrorCode::kEntityResolutionError)
-      .value("kInvalidPreflightHint", BatchElementError::ErrorCode::kInvalidPreflightHint)
-      .value("kInvalidTraitSet", BatchElementError::ErrorCode::kInvalidTraitSet);
-
-  batchElementError
-      .def(py::init<BatchElementError::ErrorCode, openassetio::Str>(), py::arg("code"),
-           py::arg("message"))
-      .def(py::self == py::self)  // NOLINT(misc-redundant-expression)
-      .def_readonly("code", &BatchElementError::code)
-      .def_readonly("message", &BatchElementError::message);
-
-  // Pybind has very limited support for custom exception types. This is
-  // a well-known tricky issue and is apparently not on the roadmap to
-  // fix. The only direct support is for an exception type that takes a
-  // single string parameter (message). However, we need `index` and
-  // `error` parameters to mirror C++.
-  //
-  // A way forward is provided by the sketch in:
-  // https://github.com/pybind/pybind11/issues/1281#issuecomment-1375950333
-  //
-  // We execute a Python string literal to create our base exception
-  // type. The `globals` and `locals` dict parameters dictate the scope
-  // of execution, so we use this to ensure the definition is scoped to
-  // our `_openassetio` module.
-  //
-  // We can then retrieve this base exception type as a pybind object
-  // and use it as the base class for pybind's limited exception
-  // registration API.
-
-  py::exec(R"pybind(
-class BatchElementException(RuntimeError):
-    def __init__(self, index: int, error):
-        self.index = index
-        self.error = error
-        super().__init__(error.message))pybind",
-           mod.attr("__dict__"), mod.attr("__dict__"));
-
-  // Retrieve a handle the the exception type just created by executing
-  // the string literal above.
-  const py::object pyBatchElementException = mod.attr("BatchElementException");
-
-  using openassetio::BatchElementException;
-  using openassetio::EntityAccessErrorBatchElementException;
-  using openassetio::EntityResolutionErrorBatchElementException;
-  using openassetio::InvalidEntityReferenceBatchElementException;
-  using openassetio::InvalidPreflightHintBatchElementException;
-  using openassetio::InvalidTraitSetBatchElementException;
-  using openassetio::MalformedEntityReferenceBatchElementException;
-  using openassetio::UnknownBatchElementException;
-
-  // Register a new exception type using `BatchElementException` base
-  // class created above. Note that this is not sufficient to cause C++
-  // exceptions to be translated. See `register_exception_translator`
-  // below.
-  const auto registerPyException = [&mod, &pyBatchElementException](const char *pyTypeName) {
-    py::exception<void /* unused */>{mod, pyTypeName, pyBatchElementException};
-  };
-
-  // Register each of our BatchElement exception types.
-  registerPyException("UnknownBatchElementException");
-  registerPyException("InvalidEntityReferenceBatchElementException");
-  registerPyException("MalformedEntityReferenceBatchElementException");
-  registerPyException("EntityAccessErrorBatchElementException");
-  registerPyException("EntityResolutionErrorBatchElementException");
-  registerPyException("InvalidPreflightHintBatchElementException");
-  registerPyException("InvalidTraitSetBatchElementException");
-
-  // Register a function that will translate our C++ exceptions to the
-  // appropriate Python exception type.
-  //
-  // Note that capturing lambdas are not allowed here, so we must
-  // `import` the exception type in the body of the function.
-  py::register_exception_translator([](std::exception_ptr pexc) {
-    if (!pexc) {
-      return;
-    }
-    const py::module_ pyModule = py::module_::import("openassetio._openassetio");
-
-    // Use CPython's PyErr_SetObject to set a custom exception type as
-    // the currently active Python exception in this thread.
-    const auto setPyException = [&pyModule](const char *pyTypeName, const auto &exc) {
-      const py::object pyClass = pyModule.attr(pyTypeName);
-      const py::object pyInstance = pyClass(exc.index, exc.error);
-      PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
-    };
-
-    // Handle the different possible C++ exceptions, creating the
-    // corresponding Python exception and setting it as the active
-    // exception in this thread.
-    try {
-      std::rethrow_exception(std::move(pexc));
-    } catch (const UnknownBatchElementException &exc) {
-      setPyException("UnknownBatchElementException", exc);
-    } catch (const InvalidEntityReferenceBatchElementException &exc) {
-      setPyException("InvalidEntityReferenceBatchElementException", exc);
-    } catch (const MalformedEntityReferenceBatchElementException &exc) {
-      setPyException("MalformedEntityReferenceBatchElementException", exc);
-    } catch (const EntityAccessErrorBatchElementException &exc) {
-      setPyException("EntityAccessErrorBatchElementException", exc);
-    } catch (const EntityResolutionErrorBatchElementException &exc) {
-      setPyException("EntityResolutionErrorBatchElementException", exc);
-    } catch (const InvalidPreflightHintBatchElementException &exc) {
-      setPyException("InvalidPreflightHintBatchElementException", exc);
-    } catch (const InvalidTraitSetBatchElementException &exc) {
-      setPyException("InvalidTraitSetBatchElementException", exc);
-    } catch (const BatchElementException &exc) {
-      setPyException("BatchElementException", exc);
-    }
-  });
-}
diff --git a/src/openassetio-python/cmodule/src/_openassetio.cpp b/src/openassetio-python/cmodule/src/_openassetio.cpp
index fbb4f5b13..b72c24d05 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.cpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.cpp
@@ -27,8 +27,8 @@ PYBIND11_MODULE(_openassetio, mod) {
   registerTraitsData(mod);
   registerManagerStateBase(managerApi);
   registerContext(mod);
-  registerBatchElementError(mod);
-  registerErrors(errors);
+  registerBatchElementError(errors);
+  registerExceptions(errors);
   registerEntityReference(mod);
   registerHostInterface(hostApi);
   registerHost(managerApi);
diff --git a/src/openassetio-python/cmodule/src/_openassetio.hpp b/src/openassetio-python/cmodule/src/_openassetio.hpp
index 9f766b371..7194778d6 100644
--- a/src/openassetio-python/cmodule/src/_openassetio.hpp
+++ b/src/openassetio-python/cmodule/src/_openassetio.hpp
@@ -113,10 +113,10 @@ void registerManagerStateBase(const py::module& mod);
 void registerEntityReference(const py::module& mod);
 
 /// Register the BatchElementError type with Python.
-void registerBatchElementError(py::module& mod);
+void registerBatchElementError(const py::module& mod);
 
-// Register errors, linking exceptions to/from Python.
-void registerErrors(const py::module& mod);
+// Register exceptions, including BatchElementExceptions.
+void registerExceptions(const py::module& mod);
 
 /// Register the EntityReferencePager class with Python.
 void registerEntityReferencePager(const py::module& mod);
diff --git a/src/openassetio-python/cmodule/src/errors/BatchElementErrorBinding.cpp b/src/openassetio-python/cmodule/src/errors/BatchElementErrorBinding.cpp
new file mode 100644
index 000000000..950789c8e
--- /dev/null
+++ b/src/openassetio-python/cmodule/src/errors/BatchElementErrorBinding.cpp
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2013-2022 The Foundry Visionmongers Ltd
+#include <string_view>
+
+#include <pybind11/operators.h>
+#include <pybind11/pybind11.h>
+
+#include <openassetio/errors/BatchElementError.hpp>
+
+#include "../_openassetio.hpp"
+
+void registerBatchElementError(const py::module &mod) {
+  using openassetio::errors::BatchElementError;
+
+  py::class_<BatchElementError> batchElementError{mod, "BatchElementError", py::is_final()};
+
+  py::enum_<BatchElementError::ErrorCode>{batchElementError, "ErrorCode"}
+      .value("kUnknown", BatchElementError::ErrorCode::kUnknown)
+      .value("kInvalidEntityReference", BatchElementError::ErrorCode::kInvalidEntityReference)
+      .value("kMalformedEntityReference", BatchElementError::ErrorCode::kMalformedEntityReference)
+      .value("kEntityAccessError", BatchElementError::ErrorCode::kEntityAccessError)
+      .value("kEntityResolutionError", BatchElementError::ErrorCode::kEntityResolutionError)
+      .value("kInvalidPreflightHint", BatchElementError::ErrorCode::kInvalidPreflightHint)
+      .value("kInvalidTraitSet", BatchElementError::ErrorCode::kInvalidTraitSet);
+
+  batchElementError
+      .def(py::init<BatchElementError::ErrorCode, openassetio::Str>(), py::arg("code"),
+           py::arg("message"))
+      .def(py::self == py::self)  // NOLINT(misc-redundant-expression)
+      .def_readonly("code", &BatchElementError::code)
+      .def_readonly("message", &BatchElementError::message);
+}
diff --git a/src/openassetio-python/cmodule/src/errorsBinding.cpp b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
similarity index 52%
rename from src/openassetio-python/cmodule/src/errorsBinding.cpp
rename to src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
index 211967832..2b1f95922 100644
--- a/src/openassetio-python/cmodule/src/errorsBinding.cpp
+++ b/src/openassetio-python/cmodule/src/errors/exceptionsBinding.cpp
@@ -2,13 +2,16 @@
 // Copyright 2013-2023 The Foundry Visionmongers Ltd
 #include <string_view>
 
+#include <pybind11/eval.h>
+#include <pybind11/operators.h>
 #include <pybind11/pybind11.h>
 
 #include <openassetio/errors/exceptions.hpp>
 
-#include "_openassetio.hpp"
+#include "../_openassetio.hpp"
 
-void registerErrors(const py::module &mod) {
+namespace {
+void registerNonBatchExceptions(const py::module &mod) {
   using openassetio::errors::ConfigurationException;
   using openassetio::errors::InputValidationException;
   using openassetio::errors::NotImplementedException;
@@ -25,7 +28,6 @@ void registerErrors(const py::module &mod) {
   // Register our OpenAssetIO exception types. Since these exceptions
   // simply take a string message and no other data, we can make use
   // of pybind11's limited custom exception support.
-
   const py::object pyOpenAssetIOException =
       registerPyException("OpenAssetIOException", PyExc_RuntimeError);
   // NOLINTNEXTLINE(bugprone-throw-keyword-missing)
@@ -74,3 +76,71 @@ void registerErrors(const py::module &mod) {
     }
   });
 }
+
+void registerBatchElementException(const py::module &mod) {
+  // Pybind has very limited support for custom exception types. This is
+  // a well-known tricky issue and is apparently not on the roadmap to
+  // fix. The only direct support is for an exception type that takes a
+  // single string parameter (message). However, we need `index` and
+  // `error` parameters to mirror C++.
+  //
+  // A way forward is provided by the sketch in:
+  // https://github.com/pybind/pybind11/issues/1281#issuecomment-1375950333
+  //
+  // We execute a Python string literal to create our exception
+  // type. The `globals` and `locals` dict parameters dictate the scope
+  // of execution, so we use this to ensure the definition is scoped to
+  // our `_openassetio` module.
+
+  py::exec(R"pybind(
+class BatchElementException(OpenAssetIOException):
+    def __init__(self, index: int, error, message: str):
+        self.index = index
+        self.error = error
+        self.message = message
+        super().__init__(message))pybind",
+           mod.attr("__dict__"), mod.attr("__dict__"));
+
+  // Retrieve a handle the the exception type just created by executing
+  // the string literal above.
+  const py::object pyBatchElementException = mod.attr("BatchElementException");
+
+  using openassetio::errors::BatchElementException;
+
+  // Register a function that will translate our C++ exceptions to the
+  // appropriate Python exception type.
+  //
+  // Note that capturing lambdas are not allowed here, so we must
+  // `import` the exception type in the body of the function.
+  py::register_exception_translator([](std::exception_ptr pexc) {
+    if (!pexc) {
+      return;
+    }
+    const py::module_ pyModule = py::module_::import("openassetio._openassetio.errors");
+
+    // Use CPython's PyErr_SetObject to set a custom exception type as
+    // the currently active Python exception in this thread.
+    const auto setPyException = [&pyModule](const char *pyTypeName, const auto &exc) {
+      const py::object pyClass = pyModule.attr(pyTypeName);
+      const py::object pyInstance = pyClass(exc.index, exc.error, exc.what());
+      PyErr_SetObject(pyClass.ptr(), pyInstance.ptr());
+    };
+
+    // Handle the different possible C++ exceptions, creating the
+    // corresponding Python exception and setting it as the active
+    // exception in this thread.
+    try {
+      std::rethrow_exception(std::move(pexc));
+    } catch (const BatchElementException &exc) {
+      setPyException("BatchElementException", exc);
+    }
+  });
+}
+}  // namespace
+
+void registerExceptions(const py::module &mod) {
+  registerNonBatchExceptions(mod);
+  // Make sure the non batch exception is registered first, as that
+  // includes the shared base exception.
+  registerBatchElementException(mod);
+}
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
index a821f9bab..1b70f4628 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
@@ -35,11 +35,11 @@ void validateTraitsDatas(const TraitsDatas& traitsDatas) {
 void registerManager(const py::module& mod) {
   namespace access = openassetio::access;
   namespace trait = openassetio::trait;
-  using openassetio::BatchElementError;
   using openassetio::ContextConstPtr;
   using openassetio::EntityReference;
   using openassetio::EntityReferences;
   using openassetio::TraitsDataPtr;
+  using openassetio::errors::BatchElementError;
   using openassetio::hostApi::Manager;
   using openassetio::hostApi::ManagerPtr;
   using openassetio::managerApi::HostSessionPtr;
diff --git a/src/openassetio-python/package/openassetio/__init__.py b/src/openassetio-python/package/openassetio/__init__.py
index 218039b3e..574268a49 100644
--- a/src/openassetio-python/package/openassetio/__init__.py
+++ b/src/openassetio-python/package/openassetio/__init__.py
@@ -76,15 +76,6 @@
     TraitsData,
     Context,
     EntityReference,
-    BatchElementError,
-    BatchElementException,
-    UnknownBatchElementException,
-    InvalidEntityReferenceBatchElementException,
-    MalformedEntityReferenceBatchElementException,
-    EntityAccessErrorBatchElementException,
-    EntityResolutionErrorBatchElementException,
-    InvalidPreflightHintBatchElementException,
-    InvalidTraitSetBatchElementException,
 )
 
 # pylint: disable=wrong-import-position
diff --git a/src/openassetio-python/package/openassetio/errors.py b/src/openassetio-python/package/openassetio/errors.py
index b7242ea71..9c2372546 100644
--- a/src/openassetio-python/package/openassetio/errors.py
+++ b/src/openassetio-python/package/openassetio/errors.py
@@ -20,9 +20,10 @@
 
 from . import _openassetio  # pylint: disable=no-name-in-module
 
-
 OpenAssetIOException = _openassetio.errors.OpenAssetIOException
 InputValidationException = _openassetio.errors.InputValidationException
 ConfigurationException = _openassetio.errors.ConfigurationException
 NotImplementedException = _openassetio.errors.NotImplementedException
 UnhandledException = _openassetio.errors.UnhandledException
+BatchElementError = _openassetio.errors.BatchElementError
+BatchElementException = _openassetio.errors.BatchElementException


Test patch:
diff --git a/examples/host/simpleResolver/test_simpleResolver.py b/examples/host/simpleResolver/test_simpleResolver.py
index 56bb49f98..7f4df9e1e 100644
--- a/examples/host/simpleResolver/test_simpleResolver.py
+++ b/examples/host/simpleResolver/test_simpleResolver.py
@@ -23,7 +23,7 @@
 import pytest
 import sys
 
-from openassetio import BatchElementError
+from openassetio.errors import BatchElementError
 from openassetio.hostApi import ManagerFactory
 
 
diff --git a/src/openassetio-core/tests/BatchElementErrorTest.cpp b/src/openassetio-core/tests/BatchElementErrorTest.cpp
index fb95ec11b..1c053030e 100644
--- a/src/openassetio-core/tests/BatchElementErrorTest.cpp
+++ b/src/openassetio-core/tests/BatchElementErrorTest.cpp
@@ -7,7 +7,7 @@
 
 #include <openassetio/errors/BatchElementError.hpp>
 
-using openassetio::BatchElementError;
+using openassetio::errors::BatchElementError;
 
 SCENARIO("BatchElementError usage") {
   GIVEN("BatchElementError is copyable") {
diff --git a/src/openassetio-core/tests/hostApi/ManagerTest.cpp b/src/openassetio-core/tests/hostApi/ManagerTest.cpp
index 6c77d4e0c..dbad3419c 100644
--- a/src/openassetio-core/tests/hostApi/ManagerTest.cpp
+++ b/src/openassetio-core/tests/hostApi/ManagerTest.cpp
@@ -10,6 +10,7 @@
 
 #include <openassetio/Context.hpp>
 #include <openassetio/TraitsData.hpp>
+#include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/HostInterface.hpp>
 #include <openassetio/hostApi/Manager.hpp>
 #include <openassetio/log/LoggerInterface.hpp>
@@ -82,6 +83,19 @@ struct ManagerFixture {
 }  // namespace OPENASSETIO_CORE_ABI_VERSION
 }  // namespace openassetio
 
+namespace {
+/* Oft-reused predicate to check if an error matches an exception by
+code and message*/
+auto makeErrorExceptionMatchPredicate(const openassetio::errors::BatchElementError& expected) {
+  return Catch::Predicate<openassetio::errors::BatchElementException>(
+      [&expected](const openassetio::errors::BatchElementException& e) -> bool {
+        return e.error.code == expected.code &&
+               std::string(e.what()).find(expected.message) != std::string::npos;
+      },
+      "Thrown exception has unexpected message or code");
+}
+}  // namespace
+
 SCENARIO("Manager constructor is private") {
   STATIC_REQUIRE_FALSE(std::is_constructible_v<openassetio::hostApi::Manager,
                                                openassetio::managerApi::ManagerInterfacePtr>);
@@ -124,7 +138,7 @@ SCENARIO("Resolving entities") {
         THEN("returned TraitsData is as expected") { CHECK(expected.get() == actual.get()); }
       }
       WHEN("singular resolve is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::TraitsDataPtr> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::TraitsDataPtr> actual =
             manager->resolve(ref, traits, resolveAccess, context,
                              hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected TraitsData") {
@@ -166,7 +180,8 @@ SCENARIO("Resolving entities") {
         THEN("returned list of TraitsDatas is as expected") { CHECK(expectedVec == actualVec); }
       }
       WHEN("batch resolve is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::TraitsDataPtr>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::TraitsDataPtr>>
             actualVec = manager->resolve(refs, traits, resolveAccess, context,
                                          hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected TraitsDatas") {
@@ -215,7 +230,8 @@ SCENARIO("Resolving entities") {
         }
       }
       WHEN("batch resolve is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::TraitsDataPtr>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::TraitsDataPtr>>
             actualVec = manager->resolve(refs, traits, resolveAccess, context,
                                          hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants is ordered in index order") {
@@ -233,8 +249,9 @@ SCENARIO("Resolving entities") {
       const openassetio::EntityReference ref = openassetio::EntityReference{"testReference"};
       const openassetio::EntityReferences refs = {ref};
 
-      openassetio::BatchElementError expected{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference, "Error Message"};
+      openassetio::errors::BatchElementError expected{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
+          "Error Message"};
 
       // With error callback side effect
       REQUIRE_CALL(mockManagerInterface,
@@ -244,8 +261,8 @@ SCENARIO("Resolving entities") {
       WHEN("singular resolve is called with default errorPolicyTag") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(manager->resolve(ref, traits, resolveAccess, context),
-                               openassetio::MalformedEntityReferenceBatchElementException,
-                               Catch::Message("Error Message"));
+                               openassetio::errors::BatchElementException,
+                               makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular resolve is called with kException errorPolicyTag") {
@@ -253,17 +270,17 @@ SCENARIO("Resolving entities") {
           CHECK_THROWS_MATCHES(
               manager->resolve(ref, traits, resolveAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Error Message"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular resolve is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::TraitsDataPtr> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::TraitsDataPtr> actual =
             manager->resolve(ref, traits, resolveAccess, context,
                              hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected BatchElementError") {
-          CHECK(std::holds_alternative<openassetio::BatchElementError>(actual));
-          const auto& actualVal = std::get<openassetio::BatchElementError>(actual);
+          CHECK(std::holds_alternative<openassetio::errors::BatchElementError>(actual));
+          const auto& actualVal = std::get<openassetio::errors::BatchElementError>(actual);
           CHECK(expected == actualVal);
         }
       }
@@ -277,11 +294,11 @@ SCENARIO("Resolving entities") {
 
       const openassetio::TraitsDataPtr expectedValue2 = openassetio::TraitsData::make();
       expectedValue2->addTrait("aTestTrait");
-      const openassetio::BatchElementError expectedError0{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference,
+      const openassetio::errors::BatchElementError expectedError0{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
           "Malformed Mock Error🤖"};
-      const openassetio::BatchElementError expectedError1{
-          openassetio::BatchElementError::ErrorCode::kEntityAccessError,
+      const openassetio::errors::BatchElementError expectedError1{
+          openassetio::errors::BatchElementError::ErrorCode::kEntityAccessError,
           "Entity Access Error Message"};
 
       // With mixed callback side effect
@@ -294,8 +311,8 @@ SCENARIO("Resolving entities") {
       WHEN("batch resolve is called with default errorPolicyTag") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(manager->resolve(refs, traits, resolveAccess, context),
-                               openassetio::MalformedEntityReferenceBatchElementException,
-                               Catch::Message("Malformed Mock Error🤖"));
+                               openassetio::errors::BatchElementException,
+                               makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch resolve is called with kException errorPolicyTag") {
@@ -303,19 +320,20 @@ SCENARIO("Resolving entities") {
           CHECK_THROWS_MATCHES(
               manager->resolve(refs, traits, resolveAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Malformed Mock Error🤖"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch resolve is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::TraitsDataPtr>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::TraitsDataPtr>>
             actualVec = manager->resolve(refs, traits, resolveAccess, context,
                                          hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected objects") {
-          auto error0 = std::get<openassetio::BatchElementError>(actualVec[0]);
+          auto error0 = std::get<openassetio::errors::BatchElementError>(actualVec[0]);
           CHECK(error0 == expectedError0);
 
-          auto error1 = std::get<openassetio::BatchElementError>(actualVec[1]);
+          auto error1 = std::get<openassetio::errors::BatchElementError>(actualVec[1]);
           CHECK(error1 == expectedError1);
 
           CHECK(std::get<openassetio::TraitsDataPtr>(actualVec[2]) == expectedValue2);
@@ -325,102 +343,7 @@ SCENARIO("Resolving entities") {
   }
 }
 
-using ErrorCode = openassetio::BatchElementError::ErrorCode;
-
-template <class T, ErrorCode C>
-struct BatchElementErrorMapping {
-  using ExceptionType = T;
-  static constexpr ErrorCode kErrorCode = C;
-};
-
-TEMPLATE_TEST_CASE(
-    "BatchElementError conversion to exceptions when resolving", "",
-    (BatchElementErrorMapping<openassetio::UnknownBatchElementException, ErrorCode::kUnknown>),
-    (BatchElementErrorMapping<openassetio::InvalidEntityReferenceBatchElementException,
-                              ErrorCode::kInvalidEntityReference>),
-    (BatchElementErrorMapping<openassetio::MalformedEntityReferenceBatchElementException,
-                              ErrorCode::kMalformedEntityReference>),
-    (BatchElementErrorMapping<openassetio::EntityAccessErrorBatchElementException,
-                              ErrorCode::kEntityAccessError>),
-    (BatchElementErrorMapping<openassetio::EntityResolutionErrorBatchElementException,
-                              ErrorCode::kEntityResolutionError>),
-    (BatchElementErrorMapping<openassetio::InvalidPreflightHintBatchElementException,
-                              ErrorCode::kInvalidPreflightHint>),
-    (BatchElementErrorMapping<openassetio::InvalidTraitSetBatchElementException,
-                              ErrorCode::kInvalidTraitSet>)) {
-  namespace hostApi = openassetio::hostApi;
-  using trompeloeil::_;
-
-  using ExpectedExceptionType = typename TestType::ExceptionType;
-  static constexpr ErrorCode kExpectedErrorCode = TestType::kErrorCode;
-
-  GIVEN("a configured Manager instance") {
-    const openassetio::trait::TraitSet traits = {"fakeTrait", "secondFakeTrait"};
-    const openassetio::ManagerFixture fixture;
-    const auto& manager = fixture.manager;
-    auto& mockManagerInterface = fixture.mockManagerInterface;
-    const auto& context = fixture.context;
-    const auto& hostSession = fixture.hostSession;
-
-    AND_GIVEN(
-        "manager plugin will encounter an entity-specific error when next resolving a reference") {
-      const openassetio::EntityReference ref = openassetio::EntityReference{"testReference"};
-      const openassetio::EntityReferences refs = {ref};
-      const auto resolveAccess = openassetio::access::ResolveAccess::kRead;
-
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error message"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface,
-                   resolve(refs, traits, resolveAccess, context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError));
-
-      WHEN("resolve is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->resolve(ref, traits, resolveAccess, context,
-                             hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-
-    AND_GIVEN(
-        "manager plugin will encounter entity-specific errors when next resolving multiple "
-        "references") {
-      const openassetio::EntityReferences refs = {openassetio::EntityReference{"testReference1"},
-                                                  openassetio::EntityReference{"testReference2"}};
-      const auto resolveAccess = openassetio::access::ResolveAccess::kRead;
-
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface,
-                   resolve(refs, traits, resolveAccess, context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError))
-          .LR_SIDE_EFFECT(FAIL_CHECK("Exception should have short-circuited this"));
-
-      WHEN("resolve is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->resolve(refs, traits, resolveAccess, context,
-                             hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-  }
-}
+using ErrorCode = openassetio::errors::BatchElementError::ErrorCode;
 
 SCENARIO("Preflighting entities") {
   namespace hostApi = openassetio::hostApi;
@@ -465,7 +388,7 @@ SCENARIO("Preflighting entities") {
         THEN("returned entity reference is as expected") { CHECK(expected == actual); }
       }
       WHEN("singular preflight is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::EntityReference> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference> actual =
             manager->preflight(ref, traitsData, publishingAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected entity reference") {
@@ -504,7 +427,8 @@ SCENARIO("Preflighting entities") {
         }
       }
       WHEN("batch preflight is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected entity references") {
@@ -547,7 +471,8 @@ SCENARIO("Preflighting entities") {
         }
       }
       WHEN("batch preflight is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants is ordered in index order") {
@@ -563,8 +488,9 @@ SCENARIO("Preflighting entities") {
     GIVEN(
         "manager plugin will encounter an entity-specific error when next preflighting a "
         "reference") {
-      const openassetio::BatchElementError expected{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference, "Error Message"};
+      const openassetio::errors::BatchElementError expected{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
+          "Error Message"};
 
       // With error callback side effect
       REQUIRE_CALL(mockManagerInterface, preflight(openassetio::EntityReferences{ref},
@@ -575,8 +501,8 @@ SCENARIO("Preflighting entities") {
       WHEN("singular preflight is called with default errorPolicyTag") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(manager->preflight(ref, traitsData, publishingAccess, context),
-                               openassetio::MalformedEntityReferenceBatchElementException,
-                               Catch::Message("Error Message"));
+                               openassetio::errors::BatchElementException,
+                               makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular preflight is called with kException errorPolicyTag") {
@@ -584,17 +510,17 @@ SCENARIO("Preflighting entities") {
           CHECK_THROWS_MATCHES(
               manager->preflight(ref, traitsData, publishingAccess, context,
                                  hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Error Message"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular preflight is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::EntityReference> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference> actual =
             manager->preflight(ref, traitsData, publishingAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected BatchElementError") {
-          CHECK(std::holds_alternative<openassetio::BatchElementError>(actual));
-          const auto& actualVal = std::get<openassetio::BatchElementError>(actual);
+          CHECK(std::holds_alternative<openassetio::errors::BatchElementError>(actual));
+          const auto& actualVal = std::get<openassetio::errors::BatchElementError>(actual);
           CHECK(expected == actualVal);
         }
       }
@@ -603,11 +529,11 @@ SCENARIO("Preflighting entities") {
         "manager plugin will encounter entity-specific errors when next preflighting multiple "
         "references") {
       const openassetio::EntityReference expectedValue2{"ref2"};
-      const openassetio::BatchElementError expectedError0{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference,
+      const openassetio::errors::BatchElementError expectedError0{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
           "Malformed Mock Error🤖"};
-      const openassetio::BatchElementError expectedError1{
-          openassetio::BatchElementError::ErrorCode::kEntityAccessError,
+      const openassetio::errors::BatchElementError expectedError1{
+          openassetio::errors::BatchElementError::ErrorCode::kEntityAccessError,
           "Entity Access Error Message"};
 
       // With mixed callback side effect
@@ -621,8 +547,8 @@ SCENARIO("Preflighting entities") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(
               manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Malformed Mock Error🤖"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch preflight is called with kException errorPolicyTag") {
@@ -630,19 +556,20 @@ SCENARIO("Preflighting entities") {
           CHECK_THROWS_MATCHES(
               manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context,
                                  hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Malformed Mock Error🤖"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch preflight is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected objects") {
-          auto error0 = std::get<openassetio::BatchElementError>(actualVec[0]);
+          auto error0 = std::get<openassetio::errors::BatchElementError>(actualVec[0]);
           CHECK(error0 == expectedError0);
 
-          auto error1 = std::get<openassetio::BatchElementError>(actualVec[1]);
+          auto error1 = std::get<openassetio::errors::BatchElementError>(actualVec[1]);
           CHECK(error1 == expectedError1);
 
           CHECK(std::get<openassetio::EntityReference>(actualVec[2]) == expectedValue2);
@@ -652,99 +579,6 @@ SCENARIO("Preflighting entities") {
   }
 }
 
-TEMPLATE_TEST_CASE(
-    "BatchElementError conversion to exceptions when preflighting", "",
-    (BatchElementErrorMapping<openassetio::UnknownBatchElementException, ErrorCode::kUnknown>),
-    (BatchElementErrorMapping<openassetio::InvalidEntityReferenceBatchElementException,
-                              ErrorCode::kInvalidEntityReference>),
-    (BatchElementErrorMapping<openassetio::MalformedEntityReferenceBatchElementException,
-                              ErrorCode::kMalformedEntityReference>),
-    (BatchElementErrorMapping<openassetio::EntityAccessErrorBatchElementException,
-                              ErrorCode::kEntityAccessError>),
-    (BatchElementErrorMapping<openassetio::EntityResolutionErrorBatchElementException,
-                              ErrorCode::kEntityResolutionError>),
-    (BatchElementErrorMapping<openassetio::InvalidPreflightHintBatchElementException,
-                              ErrorCode::kInvalidPreflightHint>),
-    (BatchElementErrorMapping<openassetio::InvalidTraitSetBatchElementException,
-                              ErrorCode::kInvalidTraitSet>)) {
-  namespace hostApi = openassetio::hostApi;
-  using trompeloeil::_;
-
-  using ExpectedExceptionType = typename TestType::ExceptionType;
-  static constexpr ErrorCode kExpectedErrorCode = TestType::kErrorCode;
-
-  GIVEN("a configured Manager instance") {
-    const openassetio::EntityReference ref = openassetio::EntityReference{"testReference"};
-    const openassetio::EntityReferences threeRefs = {
-        openassetio::EntityReference{"testReference1"},
-        openassetio::EntityReference{"testReference2"},
-        openassetio::EntityReference{"testReference3"}};
-
-    const openassetio::TraitsDataPtr traitsData =
-        openassetio::TraitsData::make({"fakeTrait", "secondFakeTrait"});
-    const openassetio::trait::TraitsDatas threeTraitsDatas{traitsData, traitsData, traitsData};
-
-    const openassetio::ManagerFixture fixture;
-    const auto& manager = fixture.manager;
-    auto& mockManagerInterface = fixture.mockManagerInterface;
-    const auto& context = fixture.context;
-    const auto& hostSession = fixture.hostSession;
-    const auto publishingAccess = openassetio::access::PublishingAccess::kWrite;
-
-    AND_GIVEN(
-        "manager plugin will encounter an entity-specific error when next preflighting a "
-        "reference") {
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error message"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface, preflight(openassetio::EntityReferences{ref},
-                                                   openassetio::trait::TraitsDatas{traitsData},
-                                                   publishingAccess, context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError));
-
-      WHEN("preflight is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->preflight(ref, traitsData, publishingAccess, context,
-                               hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-
-    AND_GIVEN(
-        "manager plugin will encounter entity-specific errors when next preflighting multiple "
-        "references") {
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface, preflight(threeRefs, threeTraitsDatas, publishingAccess,
-                                                   context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError))
-          .LR_SIDE_EFFECT(FAIL_CHECK("Exception should have short-circuited this"));
-
-      WHEN("preflight is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->preflight(threeRefs, threeTraitsDatas, publishingAccess, context,
-                               hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-  }
-}
-
 SCENARIO("Registering entities") {
   namespace hostApi = openassetio::hostApi;
   using trompeloeil::_;
@@ -790,7 +624,7 @@ SCENARIO("Registering entities") {
         THEN("returned EntityReference is as expected") { CHECK(expected == actual); }
       }
       WHEN("singular register is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::EntityReference> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference> actual =
             manager->register_(ref, singleTraitsData, publishingAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected EntityReference") {
@@ -833,7 +667,8 @@ SCENARIO("Registering entities") {
         }
       }
       WHEN("batch register is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->register_(refs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected EntityReferences") {
@@ -880,7 +715,8 @@ SCENARIO("Registering entities") {
         }
       }
       WHEN("batch register is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->register_(refs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants is ordered in index order") {
@@ -899,8 +735,9 @@ SCENARIO("Registering entities") {
       const openassetio::EntityReference ref = openassetio::EntityReference{"testReference"};
       const openassetio::EntityReferences refs = {ref};
 
-      const openassetio::BatchElementError expected{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference, "Error Message"};
+      const openassetio::errors::BatchElementError expected{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
+          "Error Message"};
 
       // With error callback side effect
       REQUIRE_CALL(mockManagerInterface, register_(refs, singleTraitsDatas, publishingAccess,
@@ -911,8 +748,8 @@ SCENARIO("Registering entities") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(
               manager->register_(ref, singleTraitsData, publishingAccess, context),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Error Message"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular register is called with kException errorPolicyTag") {
@@ -920,17 +757,17 @@ SCENARIO("Registering entities") {
           CHECK_THROWS_MATCHES(
               manager->register_(ref, singleTraitsData, publishingAccess, context,
                                  hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Error Message"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expected));
         }
       }
       WHEN("singular register is called with kVariant errorPolicyTag") {
-        std::variant<openassetio::BatchElementError, openassetio::EntityReference> actual =
+        std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference> actual =
             manager->register_(ref, singleTraitsData, publishingAccess, context,
                                hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned variant contains the expected BatchElementError") {
-          CHECK(std::holds_alternative<openassetio::BatchElementError>(actual));
-          const auto& actualVal = std::get<openassetio::BatchElementError>(actual);
+          CHECK(std::holds_alternative<openassetio::errors::BatchElementError>(actual));
+          const auto& actualVal = std::get<openassetio::errors::BatchElementError>(actual);
           CHECK(expected == actualVal);
         }
       }
@@ -943,11 +780,11 @@ SCENARIO("Registering entities") {
                                                   openassetio::EntityReference{"ref3"}};
 
       const openassetio::EntityReference expectedValue2{"expectedRef2"};
-      const openassetio::BatchElementError expectedError0{
-          openassetio::BatchElementError::ErrorCode::kMalformedEntityReference,
+      const openassetio::errors::BatchElementError expectedError0{
+          openassetio::errors::BatchElementError::ErrorCode::kMalformedEntityReference,
           "Malformed Mock Error🤖"};
-      const openassetio::BatchElementError expectedError1{
-          openassetio::BatchElementError::ErrorCode::kEntityAccessError,
+      const openassetio::errors::BatchElementError expectedError1{
+          openassetio::errors::BatchElementError::ErrorCode::kEntityAccessError,
           "Entity Access Error Message"};
 
       // With mixed callback side effect
@@ -961,8 +798,8 @@ SCENARIO("Registering entities") {
         THEN("an exception is thrown") {
           CHECK_THROWS_MATCHES(
               manager->register_(refs, threeTraitsDatas, publishingAccess, context),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Malformed Mock Error🤖"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch register is called with kException errorPolicyTag") {
@@ -970,19 +807,20 @@ SCENARIO("Registering entities") {
           CHECK_THROWS_MATCHES(
               manager->register_(refs, threeTraitsDatas, publishingAccess, context,
                                  hostApi::Manager::BatchElementErrorPolicyTag::kException),
-              openassetio::MalformedEntityReferenceBatchElementException,
-              Catch::Message("Malformed Mock Error🤖"));
+              openassetio::errors::BatchElementException,
+              makeErrorExceptionMatchPredicate(expectedError0));
         }
       }
       WHEN("batch register is called with kVariant errorPolicyTag") {
-        std::vector<std::variant<openassetio::BatchElementError, openassetio::EntityReference>>
+        std::vector<
+            std::variant<openassetio::errors::BatchElementError, openassetio::EntityReference>>
             actualVec = manager->register_(refs, threeTraitsDatas, publishingAccess, context,
                                            hostApi::Manager::BatchElementErrorPolicyTag::kVariant);
         THEN("returned lists of variants contains the expected objects") {
-          auto error0 = std::get<openassetio::BatchElementError>(actualVec[0]);
+          auto error0 = std::get<openassetio::errors::BatchElementError>(actualVec[0]);
           CHECK(error0 == expectedError0);
 
-          auto error1 = std::get<openassetio::BatchElementError>(actualVec[1]);
+          auto error1 = std::get<openassetio::errors::BatchElementError>(actualVec[1]);
           CHECK(error1 == expectedError1);
 
           CHECK(std::get<openassetio::EntityReference>(actualVec[2]) == expectedValue2);
@@ -991,100 +829,3 @@ SCENARIO("Registering entities") {
     }
   }
 }
-
-TEMPLATE_TEST_CASE(
-    "BatchElementError conversion to exceptions when registering", "",
-    (BatchElementErrorMapping<openassetio::UnknownBatchElementException, ErrorCode::kUnknown>),
-    (BatchElementErrorMapping<openassetio::InvalidEntityReferenceBatchElementException,
-                              ErrorCode::kInvalidEntityReference>),
-    (BatchElementErrorMapping<openassetio::MalformedEntityReferenceBatchElementException,
-                              ErrorCode::kMalformedEntityReference>),
-    (BatchElementErrorMapping<openassetio::EntityAccessErrorBatchElementException,
-                              ErrorCode::kEntityAccessError>),
-    (BatchElementErrorMapping<openassetio::EntityResolutionErrorBatchElementException,
-                              ErrorCode::kEntityResolutionError>),
-    (BatchElementErrorMapping<openassetio::InvalidPreflightHintBatchElementException,
-                              ErrorCode::kInvalidPreflightHint>),
-    (BatchElementErrorMapping<openassetio::InvalidTraitSetBatchElementException,
-                              ErrorCode::kInvalidTraitSet>)) {
-  namespace hostApi = openassetio::hostApi;
-  using trompeloeil::_;
-
-  using ExpectedExceptionType = typename TestType::ExceptionType;
-  static constexpr ErrorCode kExpectedErrorCode = TestType::kErrorCode;
-
-  GIVEN("a configured Manager instance") {
-    const openassetio::trait::TraitSet traits = {"fakeTrait", "secondFakeTrait"};
-    const openassetio::ManagerFixture fixture;
-    const auto& manager = fixture.manager;
-    auto& mockManagerInterface = fixture.mockManagerInterface;
-    const auto& context = fixture.context;
-    const auto& hostSession = fixture.hostSession;
-    const auto publishingAccess = openassetio::access::PublishingAccess::kWrite;
-
-    AND_GIVEN(
-        "manager plugin will encounter an entity-specific error when next registering a "
-        "reference") {
-      const openassetio::EntityReference ref = openassetio::EntityReference{"testReference"};
-      const openassetio::EntityReferences refs = {ref};
-
-      auto singleTraitsData = openassetio::TraitsData::make(traits);
-      const openassetio::trait::TraitsDatas singleTraitsDatas = {singleTraitsData};
-
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error message"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface, register_(refs, singleTraitsDatas, publishingAccess,
-                                                   context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError));
-
-      WHEN("register is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->register_(ref, singleTraitsData, publishingAccess, context,
-                               hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-
-    AND_GIVEN(
-        "manager plugin will encounter entity-specific errors when next registering multiple "
-        "references") {
-      const openassetio::trait::TraitsDatas threeTraitsDatas = {
-          openassetio::TraitsData::make(traits), openassetio::TraitsData::make(traits),
-          openassetio::TraitsData::make(traits)};
-
-      const openassetio::EntityReferences refs = {openassetio::EntityReference{"ref1"},
-                                                  openassetio::EntityReference{"ref2"},
-                                                  openassetio::EntityReference{"ref3"}};
-
-      const openassetio::BatchElementError expectedError{kExpectedErrorCode, "Some error"};
-
-      // With error callback side effect
-      REQUIRE_CALL(mockManagerInterface,
-                   register_(refs, threeTraitsDatas, publishingAccess, context, hostSession, _, _))
-          .LR_SIDE_EFFECT(_7(123, expectedError))
-          .LR_SIDE_EFFECT(FAIL_CHECK("Exception should have short-circuited this"));
-
-      WHEN("register is called with kException errorPolicyTag") {
-        THEN("an exception is thrown") {
-          try {
-            manager->register_(refs, threeTraitsDatas, publishingAccess, context,
-                               hostApi::Manager::BatchElementErrorPolicyTag::kException);
-            FAIL_CHECK("Exception not thrown");
-          } catch (const ExpectedExceptionType& exc) {
-            CHECK(exc.what() == expectedError.message);
-            CHECK(exc.error == expectedError);
-            CHECK(exc.index == 123);
-          }
-        }
-      }
-    }
-  }
-}
diff --git a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
index 0ce04f1c4..df074cc0f 100644
--- a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
+++ b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
@@ -37,7 +37,8 @@
 # pylint: disable=too-many-lines,unbalanced-tuple-unpacking
 
 from .harness import FixtureAugmentedTestCase
-from ... import BatchElementError, EntityReference, TraitsData
+from ... import EntityReference, TraitsData
+from ...errors import BatchElementError
 from ...access import PolicyAccess, ResolveAccess, RelationsAccess, PublishingAccess
 
 
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
index 8df2ab03c..f33970024 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/errorsTest.cpp
@@ -9,6 +9,7 @@
 
 #include <pybind11/pybind11.h>
 
+#include <openassetio/errors/BatchElementError.hpp>
 #include <openassetio/errors/exceptions.hpp>
 namespace py = pybind11;
 
@@ -30,6 +31,11 @@ void throwException(const std::string& exceptionName, const std::string& msgData
   if (exceptionName == "UnhandledException") {
     throw openassetio::errors::UnhandledException(msgData);
   }
+  if (exceptionName == "BatchElementException") {
+    auto error = openassetio::errors::BatchElementError{
+        openassetio::errors::BatchElementError::ErrorCode::kEntityAccessError, "errorMessage"};
+    throw openassetio::errors::BatchElementException(0, std::move(error), msgData);
+  }
 }
 }  // namespace
 
@@ -91,6 +97,13 @@ void registerExceptionThrower(py::module_& mod) {
             return true;
           }
         }
+        if (catchExceptionName == "BatchElementException") {
+          try {
+            throwException(throwExceptionName);
+          } catch (const openassetio::errors::BatchElementException&) {
+            return true;
+          }
+        }
         return false;
       },
       py::arg("throwExceptionName"), py::arg("catchExceptionName"));
diff --git a/src/openassetio-python/tests/cmodule/test_errors.py b/src/openassetio-python/tests/cmodule/test_errors.py
index 9eba41c3e..b32cfadd7 100644
--- a/src/openassetio-python/tests/cmodule/test_errors.py
+++ b/src/openassetio-python/tests/cmodule/test_errors.py
@@ -36,6 +36,7 @@ def test_when_cpp_exception_thrown_then_can_be_caught_as_corresponding_python_ex
             errors.InputValidationException,
             errors.NotImplementedException,
             errors.UnhandledException,
+            errors.BatchElementException,
         )
 
         for exception in exceptions:
@@ -48,6 +49,7 @@ def test_when_cpp_exception_thrown_then_can_be_caught_as_OpenAssetIOException(se
             errors.InputValidationException,
             errors.NotImplementedException,
             errors.UnhandledException,
+            errors.BatchElementException,
         )
 
         for exception in exceptions:
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index 4b53336c6..91c3a36a3 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -23,16 +23,9 @@
 from unittest import mock
 
 import pytest
+import re
 
 from openassetio import (
-    UnknownBatchElementException,
-    InvalidEntityReferenceBatchElementException,
-    MalformedEntityReferenceBatchElementException,
-    EntityAccessErrorBatchElementException,
-    EntityResolutionErrorBatchElementException,
-    InvalidPreflightHintBatchElementException,
-    InvalidTraitSetBatchElementException,
-    BatchElementError,
     Context,
     EntityReference,
     TraitsData,
@@ -40,9 +33,14 @@
     constants,
     access,
 )
+from openassetio.access import kAccessNames
+from openassetio.errors import (
+    BatchElementException,
+    BatchElementError,
+    InputValidationException,
+)
 from openassetio.hostApi import Manager, EntityReferencePager
 from openassetio.managerApi import EntityReferencePagerInterface
-from openassetio.errors import InputValidationException
 
 
 ## @todo Remove comments regarding Entity methods when splitting them from core API
@@ -1108,20 +1106,6 @@ def call_callbacks(*_args):
     BatchElementError.ErrorCode.kInvalidTraitSet,
 ]
 
-batch_element_exceptions = [
-    UnknownBatchElementException,
-    InvalidEntityReferenceBatchElementException,
-    MalformedEntityReferenceBatchElementException,
-    EntityAccessErrorBatchElementException,
-    EntityResolutionErrorBatchElementException,
-    InvalidPreflightHintBatchElementException,
-    InvalidTraitSetBatchElementException,
-]
-
-batch_element_error_code_exception_pairs = list(
-    zip(batch_element_error_codes, batch_element_exceptions)
-)
-
 
 class Test_Manager_resolve_with_singular_default_overload:
     def test_when_success_then_single_TraitsData_returned(
@@ -1158,48 +1142,6 @@ def call_callbacks(*_args):
 
         assert actual_traitsdata is a_traitsdata
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        an_entity_trait_set,
-        a_context,
-        invoke_resolve_error_cb,
-        error_code,
-        expected_exception,
-    ):
-        method = mock_manager_interface.mock.resolve
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_resolve_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.resolve(a_ref, an_entity_trait_set, access.ResolveAccess.kRead, a_context)
-
-        method.assert_called_once_with(
-            [a_ref],
-            an_entity_trait_set,
-            access.ResolveAccess.kRead,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_resolve_with_singular_throwing_overload:
     def test_when_resolve_success_then_single_TraitsData_returned(
@@ -1240,54 +1182,6 @@ def call_callbacks(*_args):
 
         assert actual_traitsdata is a_traitsdata
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        an_entity_trait_set,
-        a_context,
-        error_code,
-        expected_exception,
-        invoke_resolve_error_cb,
-    ):
-        method = mock_manager_interface.mock.resolve
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_resolve_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.resolve(
-                a_ref,
-                an_entity_trait_set,
-                access.ResolveAccess.kRead,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            [a_ref],
-            an_entity_trait_set,
-            access.ResolveAccess.kRead,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_resolve_with_singular_variant_overload:
     def test_when_resolve_success_then_single_TraitsData_returned(
@@ -1446,51 +1340,6 @@ def call_callbacks(*_args):
         assert actual_traitsdatas[0] is some_entity_traitsdatas[0]
         assert actual_traitsdatas[1] is some_entity_traitsdatas[1]
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        an_entity_trait_set,
-        a_context,
-        error_code,
-        a_traitsdata,
-        expected_exception,
-        invoke_resolve_success_cb,
-        invoke_resolve_error_cb,
-    ):
-        method = mock_manager_interface.mock.resolve
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_resolve_success_cb(0, a_traitsdata)
-            invoke_resolve_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.resolve(some_refs, an_entity_trait_set, access.ResolveAccess.kRead, a_context)
-
-        method.assert_called_once_with(
-            some_refs,
-            an_entity_trait_set,
-            access.ResolveAccess.kRead,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_resolve_with_batch_throwing_overload:
     def test_when_success_then_multiple_TraitsDatas_returned(
@@ -1576,57 +1425,6 @@ def call_callbacks(*_args):
         assert actual_traitsdatas[0] is some_entity_traitsdatas[0]
         assert actual_traitsdatas[1] is some_entity_traitsdatas[1]
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        an_entity_trait_set,
-        a_context,
-        error_code,
-        a_traitsdata,
-        expected_exception,
-        invoke_resolve_success_cb,
-        invoke_resolve_error_cb,
-    ):
-        method = mock_manager_interface.mock.resolve
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_resolve_success_cb(0, a_traitsdata)
-            invoke_resolve_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.resolve(
-                some_refs,
-                an_entity_trait_set,
-                access.ResolveAccess.kRead,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            some_refs,
-            an_entity_trait_set,
-            access.ResolveAccess.kRead,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_resolve_with_batch_variant_overload:
     @pytest.mark.parametrize("error_code", batch_element_error_codes)
@@ -1895,50 +1693,6 @@ def call_callbacks(*_args):
 
         assert actual_ref == a_different_ref
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        a_traitsdata,
-        a_context,
-        error_code,
-        expected_exception,
-        invoke_preflight_error_cb,
-    ):
-        method = mock_manager_interface.mock.preflight
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_preflight_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.preflight(
-                a_ref, a_traitsdata, access.PublishingAccess.kCreateRelated, a_context
-            )
-
-        method.assert_called_once_with(
-            [a_ref],
-            [a_traitsdata],
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_preflight_with_singular_throwing_overload:
     def test_when_traits_data_is_None_then_TypeError_is_raised(self, manager, a_ref, a_context):
@@ -1989,55 +1743,6 @@ def call_callbacks(*_args):
 
         assert actual_ref == a_different_ref
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        a_traitsdata,
-        a_context,
-        error_code,
-        expected_exception,
-        invoke_preflight_error_cb,
-    ):
-        method = mock_manager_interface.mock.preflight
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            # Success
-            invoke_preflight_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.preflight(
-                a_ref,
-                a_traitsdata,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            [a_ref],
-            [a_traitsdata],
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_preflight_with_singular_variant_overload:
     def test_when_traits_data_is_None_then_TypeError_is_raised(self, manager, a_ref, a_context):
@@ -2215,56 +1920,6 @@ def call_callbacks(*_args):
 
         assert actual_refs == some_different_refs
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        some_entity_traitsdatas,
-        a_context,
-        error_code,
-        a_different_ref,
-        expected_exception,
-        invoke_preflight_success_cb,
-        invoke_preflight_error_cb,
-    ):
-        method = mock_manager_interface.mock.preflight
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_preflight_success_cb(0, a_different_ref)
-            invoke_preflight_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.preflight(
-                some_refs,
-                some_entity_traitsdatas,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-            )
-
-        method.assert_called_once_with(
-            some_refs,
-            some_entity_traitsdatas,
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_preflight_with_batch_throwing_overload:
     def test_when_traits_data_is_None_then_InputValidationException_is_raised(
@@ -2358,57 +2013,6 @@ def call_callbacks(*_args):
 
         assert actual_refs == some_different_refs
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        some_entity_traitsdatas,
-        a_context,
-        error_code,
-        a_different_ref,
-        expected_exception,
-        invoke_preflight_success_cb,
-        invoke_preflight_error_cb,
-    ):
-        method = mock_manager_interface.mock.preflight
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_preflight_success_cb(0, a_different_ref)
-            invoke_preflight_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.preflight(
-                some_refs,
-                some_entity_traitsdatas,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            some_refs,
-            some_entity_traitsdatas,
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_preflight_with_batch_variant_overload:
     def test_when_traits_data_is_None_then_InputValidationException_is_raised(
@@ -2659,50 +2263,6 @@ def call_callbacks(*_args):
 
         assert actual_ref == a_different_ref
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        a_context,
-        error_code,
-        expected_exception,
-        a_traitsdata,
-        invoke_register_error_cb,
-    ):
-        method = mock_manager_interface.mock.register
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_register_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.register(
-                a_ref, a_traitsdata, access.PublishingAccess.kCreateRelated, a_context
-            )
-
-        method.assert_called_once_with(
-            [a_ref],
-            [a_traitsdata],
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_register_with_singular_throwing_overload:
     def test_when_register_success_then_single_EntityReference_returned(
@@ -2743,54 +2303,6 @@ def call_callbacks(*_args):
 
         assert actual_ref == a_different_ref
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        a_ref,
-        a_context,
-        error_code,
-        expected_exception,
-        a_traitsdata,
-        invoke_register_error_cb,
-    ):
-        method = mock_manager_interface.mock.register
-
-        expected_index = 213
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_register_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.register(
-                a_ref,
-                a_traitsdata,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            [a_ref],
-            [a_traitsdata],
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_register_with_singular_variant_overload:
     def test_when_register_success_then_single_EntityReference_returned(
@@ -2945,56 +2457,6 @@ def call_callbacks(*_args):
 
         assert actual_refs == some_different_refs
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        some_entity_traitsdatas,
-        a_context,
-        error_code,
-        a_different_ref,
-        expected_exception,
-        invoke_register_success_cb,
-        invoke_register_error_cb,
-    ):
-        method = mock_manager_interface.mock.register
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_register_success_cb(0, a_different_ref)
-            invoke_register_error_cb(expected_index, batch_element_error)
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.register(
-                some_refs,
-                some_entity_traitsdatas,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-            )
-
-        method.assert_called_once_with(
-            some_refs,
-            some_entity_traitsdatas,
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_register_with_batch_throwing_overload:
     def test_when_success_then_multiple_EntityReferences_returned(
@@ -3075,58 +2537,6 @@ def call_callbacks(*_args):
 
         assert actual_refs == some_different_refs
 
-    @pytest.mark.parametrize(
-        "error_code,expected_exception", batch_element_error_code_exception_pairs
-    )
-    def test_when_BatchElementError_then_appropriate_exception_raised(
-        self,
-        manager,
-        mock_manager_interface,
-        a_host_session,
-        some_refs,
-        some_entity_traitsdatas,
-        a_context,
-        error_code,
-        a_different_ref,
-        expected_exception,
-        invoke_register_success_cb,
-        invoke_register_error_cb,
-    ):
-        method = mock_manager_interface.mock.register
-        expected_index = 123
-
-        batch_element_error = BatchElementError(error_code, "some string ✨")
-
-        def call_callbacks(*_args):
-            invoke_register_success_cb(0, a_different_ref)
-            invoke_register_error_cb(expected_index, batch_element_error)
-
-            pytest.fail("Exception should have short-circuited this")
-
-        method.side_effect = call_callbacks
-
-        with pytest.raises(expected_exception, match=batch_element_error.message) as exc:
-            manager.register(
-                some_refs,
-                some_entity_traitsdatas,
-                access.PublishingAccess.kCreateRelated,
-                a_context,
-                Manager.BatchElementErrorPolicyTag.kException,
-            )
-
-        method.assert_called_once_with(
-            some_refs,
-            some_entity_traitsdatas,
-            access.PublishingAccess.kCreateRelated,
-            a_context,
-            a_host_session,
-            mock.ANY,
-            mock.ANY,
-        )
-
-        assert exc.value.index == expected_index
-        assert exc.value.error == batch_element_error
-
 
 class Test_Manager_register_with_batch_variant_overload:
     @pytest.mark.parametrize("error_code", batch_element_error_codes)
@@ -3367,6 +2777,248 @@ def test_when_empty_then_no_state_and_stateFromPersistenceToken_is_not_called(
         mock_manager_interface.mock.stateFromPersistenceToken.assert_not_called()
 
 
+batch_element_error_codes_names = [
+    "unknown",
+    "invalidEntityReference",
+    "malformedEntityReference",
+    "entityAccessError",
+    "entityResolutionError",
+    "invalidPreflightHint",
+    "invalidTraitSet",
+]
+
+
+def make_expected_err_msg(batch_element_error, index, access, entityRef):
+    error_type_name = batch_element_error_codes_names[
+        batch_element_error_codes.index(batch_element_error.code)
+    ]
+    return f"{error_type_name}: {batch_element_error.message} [index={index}] [access={kAccessNames[access]}] [entity={entityRef}]"
+
+
+# Conveniences to allow us to paramaterize singular and batch
+def ensure_list(item):
+    return item if isinstance(item, list) else [item]
+
+
+def ensure_singular(item):
+    return item[0] if isinstance(item, list) else item
+
+
+class Test_Manager_convenience_exceptions:
+    @pytest.mark.parametrize("error_code", batch_element_error_codes)
+    @pytest.mark.parametrize("access", [access.ResolveAccess.kRead, access.ResolveAccess.kWrite])
+    @pytest.mark.parametrize("singular", [True, False])
+    @pytest.mark.parametrize("tag", [[], [Manager.BatchElementErrorPolicyTag.kException]])
+    def test_when_batch_resolve_emits_BatchElementError_then_appropriate_exception_raised(
+        self,
+        manager,
+        mock_manager_interface,
+        a_host_session,
+        some_refs,
+        an_entity_trait_set,
+        a_context,
+        error_code,
+        access,
+        singular,
+        tag,
+        invoke_resolve_error_cb,
+    ):
+        method = mock_manager_interface.mock.resolve
+
+        expected_index = 0
+
+        # We're parametrizing over different signatures here for
+        # brevity, we want to invoke different overloads, either the
+        # singular convenience or the batch method.
+        singular_or_multi_ref = some_refs[0] if singular else some_refs
+
+        batch_element_error = BatchElementError(error_code, "some string ✨")
+
+        def call_callbacks(*_args):
+            invoke_resolve_error_cb(expected_index, batch_element_error)
+            pytest.fail("Exception should have short-circuited this")
+
+        method.side_effect = call_callbacks
+
+        with pytest.raises(
+            BatchElementException,
+            match=re.escape(
+                make_expected_err_msg(
+                    batch_element_error,
+                    expected_index,
+                    access,
+                    ensure_singular(singular_or_multi_ref),
+                )
+            ),
+        ) as exc:
+            manager.resolve(
+                singular_or_multi_ref,
+                an_entity_trait_set,
+                access,
+                a_context,
+                *tag,
+            )
+
+        # Remember this is the managerInterface, always takes a list
+        # regardless of convenience called.
+        method.assert_called_once_with(
+            ensure_list(singular_or_multi_ref),
+            an_entity_trait_set,
+            access,
+            a_context,
+            a_host_session,
+            mock.ANY,
+            mock.ANY,
+        )
+
+        assert exc.value.index == expected_index
+        assert exc.value.error == batch_element_error
+
+    @pytest.mark.parametrize("error_code", batch_element_error_codes)
+    @pytest.mark.parametrize(
+        "access", [access.PublishingAccess.kWrite, access.PublishingAccess.kCreateRelated]
+    )
+    @pytest.mark.parametrize("singular", [True, False])
+    @pytest.mark.parametrize("tag", [[], [Manager.BatchElementErrorPolicyTag.kException]])
+    def test_when_batch_preflight_emits_BatchElementError_then_appropriate_exception_raised(
+        self,
+        manager,
+        mock_manager_interface,
+        a_host_session,
+        some_refs,
+        some_entity_traitsdatas,
+        a_context,
+        error_code,
+        access,
+        singular,
+        tag,
+        invoke_preflight_error_cb,
+    ):
+        method = mock_manager_interface.mock.preflight
+
+        expected_index = 0
+
+        # We're parametrizing over different signatures here for
+        # brevity, we want to invoke different overloads, either the
+        # singular convenience or the batch method.
+        singular_or_multi_ref = some_refs[0] if singular else some_refs
+        singular_or_multi_traitsdatas = (
+            some_entity_traitsdatas[0] if singular else some_entity_traitsdatas
+        )
+
+        batch_element_error = BatchElementError(error_code, "some string ✨")
+
+        def call_callbacks(*_args):
+            invoke_preflight_error_cb(expected_index, batch_element_error)
+            pytest.fail("Exception should have short-circuited this")
+
+        method.side_effect = call_callbacks
+
+        with pytest.raises(
+            BatchElementException,
+            match=re.escape(
+                make_expected_err_msg(
+                    batch_element_error,
+                    expected_index,
+                    access,
+                    ensure_singular(singular_or_multi_ref),
+                )
+            ),
+        ) as exc:
+            manager.preflight(
+                singular_or_multi_ref, singular_or_multi_traitsdatas, access, a_context, *tag
+            )
+
+        # Remember this is the managerInterface, always takes a list
+        # regardless of convenience called.
+        method.assert_called_once_with(
+            ensure_list(singular_or_multi_ref),
+            ensure_list(singular_or_multi_traitsdatas),
+            access,
+            a_context,
+            a_host_session,
+            mock.ANY,
+            mock.ANY,
+        )
+
+        assert exc.value.index == expected_index
+        assert exc.value.error == batch_element_error
+
+    @pytest.mark.parametrize("error_code", batch_element_error_codes)
+    @pytest.mark.parametrize(
+        "access", [access.PublishingAccess.kWrite, access.PublishingAccess.kCreateRelated]
+    )
+    @pytest.mark.parametrize("singular", [True, False])
+    @pytest.mark.parametrize("tag", [[], [Manager.BatchElementErrorPolicyTag.kException]])
+    def test_when_batch_register_emits_BatchElementError_then_appropriate_exception_raised(
+        self,
+        manager,
+        a_host_session,
+        mock_manager_interface,
+        some_refs,
+        some_entity_traitsdatas,
+        a_context,
+        error_code,
+        access,
+        tag,
+        singular,
+        invoke_register_error_cb,
+    ):
+        method = mock_manager_interface.mock.register
+        expected_index = 0
+
+        # We're parametrizing over different signatures here for
+        # brevity, we want to invoke different overloads, either the
+        # singular convenience or the batch method.
+        singular_or_multi_ref = some_refs[0] if singular else some_refs
+        singular_or_multi_traitsdatas = (
+            some_entity_traitsdatas[0] if singular else some_entity_traitsdatas
+        )
+
+        batch_element_error = BatchElementError(error_code, "some string ✨")
+
+        def call_callbacks(*_args):
+            invoke_register_error_cb(expected_index, batch_element_error)
+
+            pytest.fail("Exception should have short-circuited this")
+
+        method.side_effect = call_callbacks
+
+        with pytest.raises(
+            BatchElementException,
+            match=re.escape(
+                make_expected_err_msg(
+                    batch_element_error,
+                    expected_index,
+                    access,
+                    ensure_singular(singular_or_multi_ref),
+                )
+            ),
+        ) as exc:
+            manager.register(
+                singular_or_multi_ref,
+                singular_or_multi_traitsdatas,
+                access,
+                a_context,
+                *tag,
+            )
+
+        # Remember this is the managerInterface, always takes a list
+        # regardless of convenience called.
+        method.assert_called_once_with(
+            ensure_list(singular_or_multi_ref),
+            ensure_list(singular_or_multi_traitsdatas),
+            access,
+            a_context,
+            a_host_session,
+            mock.ANY,
+            mock.ANY,
+        )
+
+        assert exc.value.index == expected_index
+        assert exc.value.error == batch_element_error
+
+
 @pytest.fixture
 def manager(mock_manager_interface, a_host_session):
     # Default to accepting anything as an entity reference string, to
diff --git a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
index c8aadaeab..854fecfd5 100644
--- a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
+++ b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
@@ -25,7 +25,7 @@
 
 import pytest
 
-from openassetio import EntityReference, TraitsData, Context, BatchElementError, errors
+from openassetio import EntityReference, TraitsData, Context, errors
 from openassetio.managerApi import (
     ManagerInterface,
     ManagerStateBase,
diff --git a/src/openassetio-python/tests/package/test_batchelementerror.py b/src/openassetio-python/tests/package/test_batchelementerror.py
index 39dcdbf2d..07e65e087 100644
--- a/src/openassetio-python/tests/package/test_batchelementerror.py
+++ b/src/openassetio-python/tests/package/test_batchelementerror.py
@@ -22,18 +22,9 @@
 # pylint: disable=missing-class-docstring,missing-function-docstring
 
 import pytest
+import re
 
-from openassetio import (
-    BatchElementError,
-    BatchElementException,
-    UnknownBatchElementException,
-    InvalidEntityReferenceBatchElementException,
-    MalformedEntityReferenceBatchElementException,
-    EntityAccessErrorBatchElementException,
-    EntityResolutionErrorBatchElementException,
-    InvalidPreflightHintBatchElementException,
-    InvalidTraitSetBatchElementException,
-)
+from openassetio.errors import BatchElementError, BatchElementException, OpenAssetIOException
 
 
 class Test_BatchElementError_ErrorCode:
@@ -117,45 +108,38 @@ def test_when_comparing_with_different_message_then_are_not_equal(
         assert a_batch_element_error != b_batch_element_error
 
 
-@pytest.mark.parametrize(
-    "exception_type",
-    [
-        BatchElementException,
-        UnknownBatchElementException,
-        InvalidEntityReferenceBatchElementException,
-        MalformedEntityReferenceBatchElementException,
-        EntityAccessErrorBatchElementException,
-        EntityResolutionErrorBatchElementException,
-        InvalidPreflightHintBatchElementException,
-        InvalidTraitSetBatchElementException,
-    ],
-)
-# Note: these tests are not sufficient to assert that C++ exceptions
-# are appropriately translated. This will be implicitly tested via e.g.
-# `resolve` tests.
 class Test_BatchElementException:
-    def test_can_catch_as_self_type(self, exception_type):
-        expected_message = "some string"
-        expected_code = BatchElementError.ErrorCode.kUnknown
-
-        a_batch_element_error = BatchElementError(expected_code, expected_message)
-
-        with pytest.raises(exception_type, match=expected_message) as exc:
-            raise exception_type(123, a_batch_element_error)
+    @pytest.mark.parametrize(
+        "exception_code",
+        [
+            BatchElementError.ErrorCode.kUnknown,
+            BatchElementError.ErrorCode.kInvalidEntityReference,
+            BatchElementError.ErrorCode.kMalformedEntityReference,
+            BatchElementError.ErrorCode.kEntityAccessError,
+            BatchElementError.ErrorCode.kEntityResolutionError,
+            BatchElementError.ErrorCode.kInvalidPreflightHint,
+            BatchElementError.ErrorCode.kInvalidTraitSet,
+        ],
+    )
+    def test_when_thrown_then_correct_data_set(self, exception_code):
+        error_message = "Error Message"
+        expected_exception_message = "ErrorType: Error Message [data][data][data]"
+        expected_code = exception_code
+
+        a_batch_element_error = BatchElementError(expected_code, error_message)
+
+        with pytest.raises(
+            BatchElementException, match=re.escape(expected_exception_message)
+        ) as exc:
+            raise BatchElementException(123, a_batch_element_error, expected_exception_message)
 
         assert exc.value.index == 123
         assert exc.value.error.code == expected_code
-        assert exc.value.error.message == expected_message
-
-    def test_can_catch_as_BatchElementException(self, exception_type):
-        expected_message = "some string"
-        expected_code = BatchElementError.ErrorCode.kUnknown
+        assert exc.value.error.message == error_message
+        assert exc.value.message == expected_exception_message
 
-        a_batch_element_error = BatchElementError(expected_code, expected_message)
-
-        with pytest.raises(BatchElementException, match=expected_message) as exc:
-            raise exception_type(123, a_batch_element_error)
-
-        assert exc.value.index == 123
-        assert exc.value.error.code == expected_code
-        assert exc.value.error.message == expected_message
+    def test_inherits_OpenAssetIOException(self):
+        exception = BatchElementException(
+            0, BatchElementError(BatchElementError.ErrorCode.kInvalidEntityReference, ""), ""
+        )
+        assert isinstance(exception, OpenAssetIOException)