Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Finalize abstract methods in `ManagerInterface`
## What

Update `ManagerInterface` methods to provide default implementations (as per #1101) or mark as pure virtual.

## Why

The current implementation is inconsistent with "valid behaviour"

## Acceptance Criteria

- The following methods should be pure virtual:
  - `identifier`
  - `displayName`
  - `hasCapability`
- The following methods should have no-op/return empty default implementations:
  - `initialize` (throw if any settings provided)
  - `info`
  - `settings`
  - `flushCaches`
- The following methods should throw a `NotImplementedException` (with a message instructing how to call `hasCapability` to avoid the exception and which enum to use):
  - `managementPolicy`
  - `isEntityReferenceString`,
  - `entityExists` (new `kExistenceQueries` capability)
  - `updateTerminology`
  - `resolve`
  - `preflight`
  - `register`
  - `getWithRelationship*`
  - `defaultEntityReference`
  - `createState`, `createChildState` 
  - `persistenceTokenForState`, `stateFromPersistenceToken`
- Wrappers/conveniences updated
   - `createContext` must check the `kStatefullContexts` and not throw if `createState` isn't implemented.
   - update host docs
   - Update Manager method docs to denote  optional + throws and see `hasCapabilty`
   - Update ManagerInterface method docs to denote optional and that default implementation doesn't not need to be called.
   - Check for other middleware methods that may potentially call into default method implementations that shouldn't throw.
Finalize abstract methods in `ManagerInterface`
## What

Update `ManagerInterface` methods to provide default implementations (as per #1101) or mark as pure virtual.

## Why

The current implementation is inconsistent with "valid behaviour"

## Acceptance Criteria

- The following methods should be pure virtual:
  - `identifier`
  - `displayName`
  - `hasCapability`
- The following methods should have no-op/return empty default implementations:
  - `initialize` (throw if any settings provided)
  - `info`
  - `settings`
  - `flushCaches`
- The following methods should throw a `NotImplementedException` (with a message instructing how to call `hasCapability` to avoid the exception and which enum to use):
  - `managementPolicy`
  - `isEntityReferenceString`,
  - `entityExists` (new `kExistenceQueries` capability)
  - `updateTerminology`
  - `resolve`
  - `preflight`
  - `register`
  - `getWithRelationship*`
  - `defaultEntityReference`
  - `createState`, `createChildState` 
  - `persistenceTokenForState`, `stateFromPersistenceToken`
- Wrappers/conveniences updated
   - `createContext` must check the `kStatefullContexts` and not throw if `createState` isn't implemented.
   - update host docs
   - Update Manager method docs to denote  optional + throws and see `hasCapabilty`
   - Update ManagerInterface method docs to denote optional and that default implementation doesn't not need to be called.
   - Check for other middleware methods that may potentially call into default method implementations that shouldn't throw.


Discussion / hints:
Presently, `resolve`, `preflight` and `register` are required.
Presently, `resolve`, `preflight` and `register` are required.

Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 07918e11d..827f99f36 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -72,6 +72,12 @@ v1.0.0-alpha.xx
   exceptional conveniences.
   [#1073](https://github.com/OpenAssetIO/OpenAssetIO/issues/1073)
 
+- Most non-required methods (all that must be checked with
+  `hasCapability`), now have default implementations that throw
+  `NotImplementedException` when called without a manager provided
+  implementation.
+  [#163](https://github.com/OpenAssetIO/OpenAssetIO/issues/163)
+
 ### New Features
 
 - Added new exception types, allowing all OpenAssetIO exceptions to
@@ -96,6 +102,12 @@ v1.0.0-alpha.xx
   feature.
   [#1088](https://github.com/OpenAssetIO/OpenAssetIO/issues/1088)
 
+- `Manager.createContext` does not call `ManagerInterface.createState`
+  if manager is not capable of `kStatefulContexts`, in which case the
+  `Context` is returned without a `managerState`.
+  [#163](https://github.com/OpenAssetIO/OpenAssetIO/issues/163)
+
+
 v1.0.0-alpha.14
 ---------------
 
diff --git a/doc/doxygen/src/Examples.dox b/doc/doxygen/src/Examples.dox
index 970c74af7..23e0e4a28 100644
--- a/doc/doxygen/src/Examples.dox
+++ b/doc/doxygen/src/Examples.dox
@@ -163,6 +163,11 @@
  * # for the entity. We inform the manager that we just want to read the
  * # current data for the entity (kRead). See the publishing examples
  * # for use of the alternative `kWrite` access mode.
+ *
+ * # First check that the manager has implemented resolution capability.
+ * if not manager.hasCapability(Manager.Capability.kResolution):
+ *   return
+
  * resolved_asset = manager.resolve(
  *         entity_reference, {LocatableContentTrait.kId},
  *         ResolveAccess.kRead, context)
@@ -175,23 +180,51 @@
  * an entity. In a real-world scenario, it is important to remember
  * that:
  *
+ * - Managers may not support a particular workflow at all.
  * - Managers may not handle certain types of entity (determined by
  *   their @ref trait_set).
  * - Managers may not be able to provide data for all traits.
  *
+ * @subsection Manager Capabilities
+ *
+ * The @fqref{hostApi.Manager.hasCapability} "hasCapability" method
+ * allows a host to determine which workflows that a manager has
+ * implemented.
+ *
+ * This is achieved by grouping sets of methods potentially implemented
+ * by the manager into "capabilities". These capabilities are defined
+ * in @fqref{hostApi.Manager.Capability} "Capability".
+ *
+ * Calling a method not implemented by the manager will result in an
+ * exception. Therefore hosts should check
+ * @fqref{hostApi.Manager.hasCapability} "hasCapability" before calling
+ * into any of these optional methods. The return value of
+ * @fqref{hostApi.Manager.hasCapability} "hasCapability"  is runtime
+ * invariant, post-@fqref{hostApi.Manager.initialize} "initialize",
+ * therefore the check only needs to be made once per manager.
+ *
+ * @subsection Management Policy
+ *
  * The @fqref{hostApi.Manager.managementPolicy} "managementPolicy"
- * method allows a host to query a manager's capabilities and its
- * intentions towards different types of entity.
+ * method allows a host to query a manager's behaviours and intentions
+ * towards different types of entity.
  *
  * This method should be used wherever possible to adapt the host's
  * behaviour so that:
  *
  * - The manager is not invoked in relation to unsupported entity types.
- * - User workflows are appropriate to the supported capabilities.
+ * - User workflows are appropriate to the supported behaviour for a
+ *   given entity type.
+ *
+ * Failing to check the policy leaves a host vulnerable to recieving
+ * empty, null or invalid data by making queries outside of the managers
+ * realm of understanding.
+ *
+ * @subsection Example
  *
  * This example demonstrates how to query a manager in relation to a
- * specific entity type - in this case a simple text file, and
- * inspect its capabilities.
+ * specific entity type - in this case a simple text file, and inspect
+ * its capabilities and the data it may be able to resolve for it.
  *
  * @code{.py}
  *
@@ -208,6 +241,10 @@
  *     # TextFileSpecification
  * )
  *
+ * # Ensure that the manager is capable of the resolution workflow.
+ * if not manager.hasCapability(Manager.Capability.kResolution):
+ *   return
+ *
  * # We use the well-known specification for a text file to determine
  * # the correct trait set to query. Using the standard definition
  * # ensures consistent behaviour across managers/hosts. We request
@@ -235,7 +272,6 @@
  *
  * if TextEncodingTrait.isImbuedTo(policy):
  *   print("The manager can provide the text encoding used")
- *
  * @endcode
  *
  *
@@ -255,6 +291,9 @@
  *     # TextFileSpecification
  * )
  *
+ * # Ensure that the manager supports publishing.
+ * if not manager.hasCapability(Manager.Capability.kPublishing):
+ *   return
  *
  * # As ever, an appropriately configured context is required
  * context = manager.createContext()
@@ -291,6 +330,11 @@
  *
  * # We then check if the manager can tell us where to save the file.
  * if ResolvesFutureEntitiesTrait.isImbuedTo(policy):
+ *
+ *     # Note we haven't used  `hasCapability` to check the resolution
+ *     # capability, as `ResolvedFutureEntitiesTrait` being imbued
+ *     # assures us the functionality is available.
+ *
  *     working_data = manager.resolve(
  *             working_ref, TextFileSpecification.kTraitSet,
  *             ResolveAccess.kWrite, context)
@@ -332,6 +376,10 @@
  *
  * @code{.py}
  *
+ * # Ensure that the manager supports publishing.
+ * if not manager.hasCapability(Manager.Capability.kPublishing):
+ *   return
+ *
  * # See if the manager wants a thumbnail
  * if not WantsThumbnailTrait.isImbuedTo(policy):
  *   return
diff --git a/doc/doxygen/src/ForHosts.dox b/doc/doxygen/src/ForHosts.dox
index fdb3231c8..b8ffe1516 100644
--- a/doc/doxygen/src/ForHosts.dox
+++ b/doc/doxygen/src/ForHosts.dox
@@ -98,6 +98,11 @@
  *   user with an additional manual configuration mechanism for your
  *   specific API session if you so wish.
  *
+ * - Ask the manager if it supports the resolution workflow by calling
+ *   @fqref{hostApi.Manager.hasCapability} "Manager.hasCapability",
+ *   passing @fqref{hostApi.Manager.Capability.kResolution}
+ *   "kResolution".
+ *
  * - Create (and configure) a @ref Context as appropriate for calls to
  *   the API. Re-use context objects for all API calls that are related
  *   to the same user session or logical set of interactions. For
@@ -131,6 +136,11 @@
  *
  * @subsection host_todo_required_publishing Required for Publishing
  *
+ * - Ask the manager if it supports the publishing workflow by calling
+ *   @fqref{hostApi.Manager.hasCapability} "Manager.hasCapability",
+ *   passing @fqref{hostApi.Manager.Capability.kPublishing}
+ *   "kPublishing".
+ *
  * - Ask the @ref manager if they support publishing for the relevant
  *   data by supplying its @ref trait_set to
  *   @fqref{hostApi.Manager.managementPolicy} "Manager.managementPolicy"
diff --git a/doc/doxygen/src/ForManagers.dox b/doc/doxygen/src/ForManagers.dox
index 36fa6257a..72d36d146 100644
--- a/doc/doxygen/src/ForManagers.dox
+++ b/doc/doxygen/src/ForManagers.dox
@@ -106,8 +106,10 @@
  * - Implement the @fqref{managerApi.ManagerInterface}
  *   "ManagerInterface" class methods
  *   @fqref{managerApi.ManagerInterface.identifier} "identifier",
- *   @fqref{managerApi.ManagerInterface.displayName} "displayName" and
- *   @fqref{managerApi.ManagerInterface.hasCapability} "hasCapability".
+ *   @fqref{managerApi.ManagerInterface.displayName} "displayName",
+ *   @fqref{managerApi.ManagerInterface.hasCapability} "hasCapability"
+ *   and @fqref{managerApi.ManagerInterface.isEntityReferenceString}
+ *   "isEntityReferenceString".
  *
  * - Implement @fqref{managerApi.ManagerInterface.managementPolicy}
  *   "managementPolicy" to control which Host-side data types you wish
@@ -130,14 +132,19 @@
  * @subsection manager_todo_publishing Required for Publishing
  *
  * - Update the implementation of
+ *   @fqref{managerApi.ManagerInterface.hasCapability} "hasCapability"
+ *   to return true when queried with
+ *   @fqref{managerApi.ManagerInterface.Capability.kPublishing}
+ *   "kPublishing".
+ *
+ * - Update the implementation of
  *   @fqref{managerApi.ManagerInterface.managementPolicy}
  *   "managementPolicy" to return suitable traits for contexts with a
  *   `kWrite` access.
  *
- * - Implement the methods grouped under the "Publishing" section, and
- *   the remaining methods in the "Entity Information" section of
- *   @fqref{managerApi.ManagerInterface} "ManagerInterface", to support
- *   the registration of new assets.
+ * - Implement the methods listed under the
+ *   @fqref{managerApi.ManagerInterface.Capability.kPublishing}
+ *   "publishing" capability to support the registration of new assets.
  *
  * - Map supported OpenAssetIO @ref trait "traits" to internal asset
  *   types where possible.
@@ -204,6 +211,11 @@
  *   simplify common pipeline integration tasks. For example, loading
  *   multiple AOVs for a render, or determining data dependencies when
  *   transferring assets.
+ * - Update your implementation of
+ *   @fqref{managerApi.ManagerInterface.hasCapability} "hasCapability"
+ *   to report that you are now implement
+ *   @fqref{managerApi.ManagerInterface.Capability.kRelationshipQueries}
+ *   "relationship queries".
  * - Update your implementation of @fqref{managerApi.ManagerInterface.managementPolicy}
  *   "managementPolicy" to cover the relationship trait sets you support.
  *
diff --git a/doc/doxygen/src/ManagerState.dox b/doc/doxygen/src/ManagerState.dox
index ce1e2b627..cf57b7b59 100644
--- a/doc/doxygen/src/ManagerState.dox
+++ b/doc/doxygen/src/ManagerState.dox
@@ -32,8 +32,9 @@
  * The manager state mechanism allows a @ref manager to embed state
  * within the @ref host, such that it can correlate API calls.
  *
- * Whenever a @ref Context is created by the host, the
- * @fqref{managerApi.ManagerInterface.createState}
+ * Whenever a @ref Context is created by the host, if the manager is
+ * @fqref{managerApi.ManagerInterface.Capability.kStatefulContexts}
+ * "capable", the @fqref{managerApi.ManagerInterface.createState}
  * "ManagerInterface.createState" (or
  * @fqref{managerApi.ManagerInterface.createChildState}
  * "ManagerInterface.createChildState") method will be called, and the
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index babb2a483..8c68c3da5 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -260,6 +260,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @param terms Map of terms to be substituted by the manager.
    *
    * @return Substituted map of terms.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kCustomTerminology.
+   *
+   * @see @ref Capability.kCustomTerminology
    */
   StrMap updateTerminology(StrMap terms);
 
@@ -498,7 +505,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *  supplied here. It does not encode the current locale or other
    *  propeties.
    *
-   *  @see @ref stable_resolution
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kStatefulContexts.
+   *
+   * @see @ref Capability.kStatefulContexts
+   * @see @ref stable_resolution
    */
   Str persistenceTokenForContext(const ContextPtr& context);
 
@@ -521,6 +534,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @see @ref stable_resolution
    *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kStatefulContexts.
+   *
+   * @see @ref Capability.kStatefulContexts
+   *
    * @todo Should we concatenate the manager id in
    * persistenceTokenForContext so we can verify that they match?
    */
@@ -723,6 +743,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `entityExists`.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kExistenceQueries.
+   *
+   * @see @ref Capability.kExistenceQueries
    */
   void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
                     const ExistsSuccessCallback& successCallback,
@@ -819,6 +846,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `resolve`.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kResolution.
+   *
+   * @see @ref Capability.kResolution
    */
   void resolve(const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
                access::ResolveAccess resolveAccess, const ContextConstPtr& context,
@@ -857,6 +891,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * BatchElementErrorPolicyTag::Exception.
    *
    * @return Populated data.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kResolution.
+   *
+   * @see @ref Capability.kResolution
    */
   trait::TraitsDataPtr resolve(const EntityReference& entityReference,
                                const trait::TraitSet& traitSet,
@@ -900,6 +941,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Object containing either the populated data or an error
    * object.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kResolution.
+   *
+   * @see @ref Capability.kResolution
    */
   std::variant<errors::BatchElementError, trait::TraitsDataPtr> resolve(
       const EntityReference& entityReference, const trait::TraitSet& traitSet,
@@ -939,6 +987,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * BatchElementErrorPolicyTag::Exception.
    *
    * @return List of populated data objects.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kResolution.
+   *
+   * @see @ref Capability.kResolution
    */
   std::vector<trait::TraitsDataPtr> resolve(
       const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
@@ -984,6 +1039,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return List of objects, each containing either the populated data
    * or an error.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kResolution.
+   *
+   * @see @ref Capability.kResolution
    */
   std::vector<std::variant<errors::BatchElementError, trait::TraitsDataPtr>> resolve(
       const EntityReferences& entityReferences, const trait::TraitSet& traitSet,
@@ -1038,6 +1100,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * "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`.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kDefaultEntityReferences.
+   *
+   * @see @ref Capability.kDefaultEntityReferences
    */
   void defaultEntityReference(const trait::TraitSets& traitSets,
                               access::DefaultEntityAccess defaultEntityAccess,
@@ -1149,6 +1218,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * entities should have.
    *
    * @throws errors.InputValidationException if @p pageSize is zero.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kRelationshipQueries.
+   *
+   * @see @ref Capability.kRelationshipQueries
    */
   void getWithRelationship(const EntityReferences& entityReferences,
                            const trait::TraitsDataPtr& relationshipTraitsData, size_t pageSize,
@@ -1213,6 +1289,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * handles relationships of that type.
    *
    * @throws errors.InputValidationException if @p pageSize is zero.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kRelationshipQueries.
+   *
+   * @see @ref Capability.kRelationshipQueries
    */
   void getWithRelationships(const EntityReference& entityReference,
                             const trait::TraitsDatas& relationshipTraitsDatas, size_t pageSize,
@@ -1372,6 +1455,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * that initiated the call to `preflight`.
    *
    * @see @ref register_
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   void preflight(const EntityReferences& entityReferences, const trait::TraitsDatas& traitsHints,
                  access::PublishingAccess publishingAccess, const ContextConstPtr& context,
@@ -1415,6 +1505,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Updated reference to use for future interactions as part of
    * the publishing operation
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   EntityReference preflight(const EntityReference& entityReference,
                             const trait::TraitsDataPtr& traitsHint,
@@ -1464,6 +1561,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * interactions as part of the publishing operation, or an error
    * object detailing the reason for the failure of this particular
    * entity.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   std::variant<errors::BatchElementError, EntityReference> preflight(
       const EntityReference& entityReference, const trait::TraitsDataPtr& traitsHint,
@@ -1508,6 +1612,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Updated references to use for future interactions as part
    * of the publishing operation
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   EntityReferences preflight(const EntityReferences& entityReferences,
                              const trait::TraitsDatas& traitsHints,
@@ -1560,6 +1671,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * use for future interactions as part of the publishing operation, or
    * an error object detailing the reason for the failure of that
    * particular entity.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   std::vector<std::variant<errors::BatchElementError, EntityReference>> preflight(
       const EntityReferences& entityReferences, const trait::TraitsDatas& traitsHints,
@@ -1656,14 +1774,20 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return None
    *
-   * @exception std::out_of_range If @p entityReferences and
+   * @throws std::out_of_range If @p entityReferences and
    * @p entityTraitsDatas are not lists of the same length.
-   *
    * Other exceptions may be raised for fatal runtime errors, for
    * example server communication failure.
    *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
    * @see @fqref{trait.TraitsData} "TraitsData"
    * @see @ref preflight
+   * @see @ref Capability.kPublishing
+   *
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
   void register_(const EntityReferences& entityReferences,
@@ -1708,6 +1832,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Updated reference to use for future interactions with the
    * resulting new entity.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
   EntityReference register_(const EntityReference& entityReference,
@@ -1756,6 +1887,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @return Updated reference to use for future interactions with the
    * resulting new entity or an error object detailing the reason for
    * the failure of this particular entity.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
   std::variant<errors::BatchElementError, EntityReference> register_(
@@ -1800,6 +1938,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    *
    * @return Updated references to use for future interactions with the
    * resulting new entities.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
   std::vector<EntityReference> register_(
@@ -1852,6 +1997,13 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * use in future interactions with the resulting new entity, or
    * an error object detailing the reason for the failure of that
    * particular entity.
+   *
+   * @throws errors.NotImplementedException Thrown when this method is
+   * not implemented by the manager. Check that this method is
+   * implemented before use by calling @ref hasCapability with @ref
+   * Capability.kPublishing.
+   *
+   * @see @ref Capability.kPublishing
    */
   // NOLINTNEXTLINE(readability-identifier-naming)
   std::vector<std::variant<errors::BatchElementError, EntityReference>> register_(
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index a9c1b0e60..b8f2e51be 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -438,6 +438,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * initiated the API session.
    *
    * @return Substituted map of terms.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kCustomTerminology
    */
   [[nodiscard]] virtual StrMap updateTerminology(StrMap terms, const HostSessionPtr& hostSession);
 
@@ -499,7 +505,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * de-activated in a host, to allow any event registrations etc...
    * to be removed.
    */
-  virtual void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession) = 0;
+  virtual void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession);
 
   /**
    * Clears any internal caches.
@@ -588,11 +594,20 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @param hostSession The API session.
    *
    * @return a `TraitsData` for each element in @p traitSets.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   * This exception being thrown constitutes a manager implementation
+   * error, as all managers must implement this method in one of their
+   * component plugins.
+   *
+   * @see @ref Capability.kManagementPolicyQueries
    */
   [[nodiscard]] virtual trait::TraitsDatas managementPolicy(const trait::TraitSets& traitSets,
                                                             access::PolicyAccess policyAccess,
                                                             const ContextConstPtr& context,
-                                                            const HostSessionPtr& hostSession) = 0;
+                                                            const HostSessionPtr& hostSession);
 
   /**
    * @}
@@ -651,6 +666,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @exception exceptions.StateError If for some reason creation
    * fails.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kStatefulContexts
    * @see @ref createChildState
    * @see @ref persistenceTokenForState
    * @see @ref stateFromPersistenceToken
@@ -693,6 +713,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @exception std::runtime_error If called on a manager that does not
    * implement custom state management.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kStatefulContexts
    * @see @ref createState
    * @see @ref persistenceTokenForState
    * @see @ref stateFromPersistenceToken
@@ -711,6 +736,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @exception std::runtime_error If called on a manager that does not
    * implement custom state management.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kStatefulContexts
    * @see @ref stateFromPersistenceToken
    */
   [[nodiscard]] virtual Str persistenceTokenForState(const ManagerStateBasePtr& state,
@@ -728,6 +758,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * meaningful, or that a state has already been restored.
    * @exception std::runtime_error If called on a manager that does not
    * implement custom state management.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kStatefulContexts
    */
   [[nodiscard]] virtual ManagerStateBasePtr stateFromPersistenceToken(
       const Str& token, const HostSessionPtr& hostSession);
@@ -806,11 +842,19 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * format of the string is recognised as a potential entity reference
    * by the manager.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   * This exception being thrown constitutes a manager implementation
+   * error, as all managers must implement this method in one of their
+   * component plugins.
+   *
+   * @see @ref Capability.kEntityReferenceIdentification
    * @see @ref entityExists
    * @see @ref resolve
    */
   [[nodiscard]] virtual bool isEntityReferenceString(const Str& someString,
-                                                     const HostSessionPtr& hostSession) = 0;
+                                                     const HostSessionPtr& hostSession);
 
   /**
    * Callback signature used for a successful entity existence query.
@@ -861,11 +905,17 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * "BatchElementError" (see @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCodes"). The callback must be called on the same thread
    * that initiated the call to `entityExists`.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kExistenceQueries
    */
   virtual void entityExists(const EntityReferences& entityReferences,
                             const ContextConstPtr& context, const HostSessionPtr& hostSession,
                             const ExistsSuccessCallback& successCallback,
-                            const BatchElementErrorCallback& errorCallback) = 0;
+                            const BatchElementErrorCallback& errorCallback);
 
   /**
    * @}
@@ -958,6 +1008,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * "ErrorCodes"). The callback must be called on the same thread
    * that initiated the call to `resolve`.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kResolution
    * @see @ref entityExists
    * @see @ref isEntityReferenceString
    * @see @fqref{errors.BatchElementError} "BatchElementError"
@@ -966,7 +1021,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
                        access::ResolveAccess resolveAccess, const ContextConstPtr& context,
                        const HostSessionPtr& hostSession,
                        const ResolveSuccessCallback& successCallback,
-                       const BatchElementErrorCallback& errorCallback) = 0;
+                       const BatchElementErrorCallback& errorCallback);
 
   /**
    * Callback signature used for a successful default entity reference
@@ -1031,6 +1086,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * "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`.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kDefaultEntityReferences
    */
   virtual void defaultEntityReference(const trait::TraitSets& traitSets,
                                       access::DefaultEntityAccess defaultEntityAccess,
@@ -1150,6 +1211,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    *
    * @param resultTraitSet A hint as to what traits the returned
    * entities should have.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kRelationshipQueries
    */
   virtual void getWithRelationship(const EntityReferences& entityReferences,
                                    const trait::TraitsDataPtr& relationshipTraitsData,
@@ -1227,6 +1294,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * @note The @ref trait_set of any queried relationship can be passed
    * to @ref managementPolicy in order to determine if the manager
    * handles relationships of that type.
+   *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kRelationshipQueries
    */
   virtual void getWithRelationships(const EntityReference& entityReference,
                                     const trait::TraitsDatas& relationshipTraitsDatas,
@@ -1396,6 +1469,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * who's corresponding @p traitsHints entry holds insufficient or
    * invalid information.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kPublishing
    * @see @ref register_
    */
   virtual void preflight(const EntityReferences& entityReferences,
@@ -1403,7 +1481,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
                          access::PublishingAccess publishingAccess, const ContextConstPtr& context,
                          const HostSessionPtr& hostSession,
                          const PreflightSuccessCallback& successCallback,
-                         const BatchElementErrorCallback& errorCallback) = 0;
+                         const BatchElementErrorCallback& errorCallback);
 
   /**
    * Callback signature used for a successful register operation on a
@@ -1497,6 +1575,11 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * callback must be called on the same thread that initiated the call
    * to `register`.
    *
+   * @throws errors.NotImplementedException by default when this method
+   * is not implemented by the manager. Implementations must therefore
+   * not invoke the base class implementation.
+   *
+   * @see @ref Capability.kPublishing
    * @see @fqref{trait.TraitsData} "TraitsData"
    * @see @ref preflight
    */
@@ -1506,7 +1589,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
                          access::PublishingAccess publishingAccess, const ContextConstPtr& context,
                          const HostSessionPtr& hostSession,
                          const RegisterSuccessCallback& successCallback,
-                         const BatchElementErrorCallback& errorCallback) = 0;
+                         const BatchElementErrorCallback& errorCallback);
 
   /// @}
  protected:
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 4f50dddd2..f4f6f1a83 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -96,7 +96,9 @@ trait::TraitsDatas Manager::managementPolicy(const trait::TraitSets &traitSets,
 
 ContextPtr Manager::createContext() {
   ContextPtr context = Context::make();
-  context->managerState = managerInterface_->createState(hostSession_);
+  if (hasCapability(Capability::kStatefulContexts)) {
+    context->managerState = managerInterface_->createState(hostSession_);
+  }
   context->locale = trait::TraitsData::make();
   return context;
 }
diff --git a/src/openassetio-core/src/managerApi/ManagerInterface.cpp b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
index f47e2a1d8..79284315e 100644
--- a/src/openassetio-core/src/managerApi/ManagerInterface.cpp
+++ b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
@@ -2,6 +2,8 @@
 // Copyright 2013-2023 The Foundry Visionmongers Ltd
 #include <stdexcept>
 
+#include <fmt/format.h>
+
 #include <openassetio/errors/exceptions.hpp>
 #include <openassetio/hostApi/EntityReferencePager.hpp>
 #include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
@@ -14,11 +16,29 @@ namespace managerApi {
 
 ManagerInterface::ManagerInterface() = default;
 
+#define UNIMPLEMENTED_ERROR(capability)                                                           \
+  fmt::format(                                                                                    \
+      "The '{}' method has not been implemented by the manager. Check manager capability for {} " \
+      "by calling `manager.hasCapability`.",                                                      \
+      __func__, ManagerInterface::kCapabilityNames[static_cast<size_t>(capability)])
+
 InfoDictionary ManagerInterface::info() { return {}; }
 
-StrMap ManagerInterface::updateTerminology(StrMap terms,
+// NOLINTNEXTLINE(performance-unnecessary-value-param)
+void ManagerInterface::initialize(InfoDictionary managerSettings,
+                                  [[maybe_unused]] const HostSessionPtr& hostSession) {
+  if (!managerSettings.empty()) {
+    throw errors::InputValidationException{
+        "Settings provided but are not supported. The initialize method has not been implemented "
+        "by the manager."};
+  }
+}
+
+// NOLINTNEXTLINE(performance-unnecessary-value-param)
+StrMap ManagerInterface::updateTerminology([[maybe_unused]] StrMap terms,
                                            [[maybe_unused]] const HostSessionPtr& hostSession) {
-  return terms;
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kCustomTerminology)};
 }
 
 InfoDictionary ManagerInterface::settings([[maybe_unused]] const HostSessionPtr& hostSession) {
@@ -27,29 +47,67 @@ InfoDictionary ManagerInterface::settings([[maybe_unused]] const HostSessionPtr&
 
 void ManagerInterface::flushCaches([[maybe_unused]] const HostSessionPtr& hostSession) {}
 
+trait::TraitsDatas ManagerInterface::managementPolicy(
+    [[maybe_unused]] const trait::TraitSets& traitSets,
+    [[maybe_unused]] access::PolicyAccess policyAccess,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kManagementPolicyQueries)};
+}
+
+void ManagerInterface::entityExists(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::ExistsSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kExistenceQueries)};
+}
+
+void ManagerInterface::resolve(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const trait::TraitSet& traitSet,
+    [[maybe_unused]] access::ResolveAccess resolveAccess,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::ResolveSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kResolution)};
+}
+
 ManagerStateBasePtr ManagerInterface::createState(
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  return nullptr;
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kStatefulContexts)};
 }
 
 ManagerStateBasePtr ManagerInterface::createChildState(
     [[maybe_unused]] const ManagerStateBasePtr& parentState,
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw errors::NotImplementedException(
-      "createChildState called on a manager that does not implement a custom state.");
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kStatefulContexts)};
 }
 
 Str ManagerInterface::persistenceTokenForState(
     [[maybe_unused]] const ManagerStateBasePtr& state,
     [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw errors::NotImplementedException(
-      "persistenceTokenForState called on a manager that does not implement a custom state.");
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kStatefulContexts)};
 }
 
 ManagerStateBasePtr ManagerInterface::stateFromPersistenceToken(
     [[maybe_unused]] const Str& token, [[maybe_unused]] const HostSessionPtr& hostSession) {
-  throw errors::NotImplementedException(
-      "stateFromPersistenceToken called on a manager that does not implement a custom state.");
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kStatefulContexts)};
+}
+
+bool ManagerInterface::isEntityReferenceString(
+    [[maybe_unused]] const Str& someString, [[maybe_unused]] const HostSessionPtr& hostSession) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kEntityReferenceIdentification)};
 }
 
 // To avoid changing this to non-static in the not too distant, when we
@@ -60,16 +118,14 @@ EntityReference ManagerInterface::createEntityReference(Str entityReferenceStrin
 }
 
 void ManagerInterface::defaultEntityReference(
-    const trait::TraitSets& traitSets,
+    [[maybe_unused]] const trait::TraitSets& traitSets,
     [[maybe_unused]] const access::DefaultEntityAccess defaultEntityAccess,
     [[maybe_unused]] const ContextConstPtr& context,
     [[maybe_unused]] const HostSessionPtr& hostSession,
-    const DefaultEntityReferenceSuccessCallback& successCallback,
+    [[maybe_unused]] const DefaultEntityReferenceSuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  const auto size = traitSets.size();
-  for (size_t i = 0; i < size; ++i) {
-    successCallback(i, {});
-  }
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kDefaultEntityReferences)};
 }
 
 namespace {
@@ -88,35 +144,53 @@ class EmptyEntityReferencePagerInterface : public managerApi::EntityReferencePag
 }  // namespace
 
 void ManagerInterface::getWithRelationship(
-    const EntityReferences& entityReferences,
+    [[maybe_unused]] const EntityReferences& entityReferences,
     [[maybe_unused]] const trait::TraitsDataPtr& relationshipTraitsData,
     [[maybe_unused]] const trait::TraitSet& resultTraitSet, [[maybe_unused]] size_t pageSize,
     [[maybe_unused]] const access::RelationsAccess relationsAccess,
     [[maybe_unused]] const ContextConstPtr& context,
     [[maybe_unused]] const HostSessionPtr& hostSession,
-    const RelationshipQuerySuccessCallback& successCallback,
+    [[maybe_unused]] const RelationshipQuerySuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  const auto size = entityReferences.size();
-
-  for (std::size_t idx = 0; idx < size; ++idx) {
-    successCallback(idx, std::make_shared<EmptyEntityReferencePagerInterface>());
-  }
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kRelationshipQueries)};
 }
 
 void ManagerInterface::getWithRelationships(
     [[maybe_unused]] const EntityReference& entityReference,
-    const trait::TraitsDatas& relationshipTraitsDatas,
+    [[maybe_unused]] const trait::TraitsDatas& relationshipTraitsDatas,
     [[maybe_unused]] const trait::TraitSet& resultTraitSet, [[maybe_unused]] size_t pageSize,
     [[maybe_unused]] const access::RelationsAccess relationsAccess,
     [[maybe_unused]] const ContextConstPtr& context,
     [[maybe_unused]] const HostSessionPtr& hostSession,
-    const RelationshipQuerySuccessCallback& successCallback,
+    [[maybe_unused]] const RelationshipQuerySuccessCallback& successCallback,
     [[maybe_unused]] const BatchElementErrorCallback& errorCallback) {
-  const auto size = relationshipTraitsDatas.size();
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kRelationshipQueries)};
+}
 
-  for (std::size_t idx = 0; idx < size; ++idx) {
-    successCallback(idx, std::make_shared<EmptyEntityReferencePagerInterface>());
-  }
+void ManagerInterface::preflight(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const trait::TraitsDatas& traitsHints,
+    [[maybe_unused]] access::PublishingAccess publishingAccess,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::PreflightSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kPublishing)};
+}
+
+void ManagerInterface::register_(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const trait::TraitsDatas& entityTraitsDatas,
+    [[maybe_unused]] access::PublishingAccess publishingAccess,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::RegisterSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kPublishing)};
 }
 
 }  // namespace managerApi
diff --git a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
index 75884eb77..0b4dbacc9 100644
--- a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
@@ -46,8 +46,7 @@ struct PyManagerInterface : ManagerInterface {
   }
 
   void initialize(InfoDictionary managerSettings, const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, initialize, std::move(managerSettings),
-                           hostSession);
+    PYBIND11_OVERRIDE(void, ManagerInterface, initialize, std::move(managerSettings), hostSession);
   }
 
   void flushCaches(const HostSessionPtr& hostSession) override {
@@ -58,8 +57,8 @@ struct PyManagerInterface : ManagerInterface {
                                                     access::PolicyAccess policyAccess,
                                                     const ContextConstPtr& context,
                                                     const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(trait::TraitsDatas, ManagerInterface, managementPolicy, traitSets,
-                           policyAccess, context, hostSession);
+    PYBIND11_OVERRIDE(trait::TraitsDatas, ManagerInterface, managementPolicy, traitSets,
+                      policyAccess, context, hostSession);
   }
 
   ManagerStateBasePtr createState(const HostSessionPtr& hostSession) override {
@@ -85,16 +84,15 @@ struct PyManagerInterface : ManagerInterface {
 
   [[nodiscard]] bool isEntityReferenceString(const Str& someString,
                                              const HostSessionPtr& hostSession) override {
-    PYBIND11_OVERRIDE_PURE(bool, ManagerInterface, isEntityReferenceString, someString,
-                           hostSession);
+    PYBIND11_OVERRIDE(bool, ManagerInterface, isEntityReferenceString, someString, hostSession);
   }
 
   void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
                     const HostSessionPtr& hostSession,
                     const ExistsSuccessCallback& successCallback,
                     const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, entityExists, entityReferences, context,
-                           hostSession, successCallback, errorCallback);
+    PYBIND11_OVERRIDE(void, ManagerInterface, entityExists, entityReferences, context, hostSession,
+                      successCallback, errorCallback);
   }
 
   [[nodiscard]] bool hasCapability(ManagerInterface::Capability capability) override {
@@ -110,8 +108,8 @@ struct PyManagerInterface : ManagerInterface {
                const access::ResolveAccess resolveAccess, const ContextConstPtr& context,
                const HostSessionPtr& hostSession, const ResolveSuccessCallback& successCallback,
                const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, resolve, entityReferences, traitSet,
-                           resolveAccess, context, hostSession, successCallback, errorCallback);
+    PYBIND11_OVERRIDE(void, ManagerInterface, resolve, entityReferences, traitSet, resolveAccess,
+                      context, hostSession, successCallback, errorCallback);
   }
 
   void defaultEntityReference(const trait::TraitSets& traitSets,
@@ -158,16 +156,17 @@ struct PyManagerInterface : ManagerInterface {
                  const HostSessionPtr& hostSession,
                  const PreflightSuccessCallback& successCallback,
                  const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, preflight, entityReferences, traitsHints,
-                           publishingAccess, context, hostSession, successCallback, errorCallback);
+    PYBIND11_OVERRIDE(void, ManagerInterface, preflight, entityReferences, traitsHints,
+                      publishingAccess, context, hostSession, successCallback, errorCallback);
   }
 
   void register_(const EntityReferences& entityReferences, const trait::TraitsDatas& traitsDatas,
                  const access::PublishingAccess publishingAccess, const ContextConstPtr& context,
                  const HostSessionPtr& hostSession, const RegisterSuccessCallback& successCallback,
                  const BatchElementErrorCallback& errorCallback) override {
-    PYBIND11_OVERRIDE_PURE(void, ManagerInterface, register, entityReferences, traitsDatas,
-                           publishingAccess, context, hostSession, successCallback, errorCallback);
+    PYBIND11_OVERRIDE_NAME(void, ManagerInterface, "register", register_, entityReferences,
+                           traitsDatas, publishingAccess, context, hostSession, successCallback,
+                           errorCallback);
   }
 
   // Hoist protected members


Test patch:
diff --git a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
index 84d711fd1..45ecb8d2a 100644
--- a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
+++ b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
@@ -98,6 +98,9 @@ class Test_updateTerminology(FixtureAugmentedTestCase):
     """
 
     def test_output_contains_input_terms(self):
+        if not self._manager.hasCapability(self._manager.Capability.kCustomTerminology):
+            self.skipTest("kCustomTerminology capability not implemented")
+
         terms = {"aTermKey🔥 ": "aTermValue🎖️", "aSecondTermKey": "aSecondTermValue"}
         return_terms = self._manager.updateTerminology(terms)
         self.assertEqual(sorted(terms.keys()), sorted(return_terms.keys()))
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index 6bd002fcc..d722f9573 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -2433,17 +2433,40 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(Manager.createContext)
         assert method_introspector.is_implemented_once(Manager, "createContext")
 
-    def test_context_is_created_with_expected_properties(
+    def test_context_is_created_with_locale(self, manager, mock_manager_interface, a_host_session):
+        mock_manager_interface.mock.hasCapability.return_value = False
+
+        context_a = manager.createContext()
+
+        assert isinstance(context_a.locale, TraitsData)
+        assert context_a.locale.traitSet() == set()
+
+    def test_when_custom_state_not_supported_then_create_state_not_called(
+        self, manager, mock_manager_interface, a_host_session
+    ):
+        mock_manager_interface.mock.hasCapability.return_value = False
+
+        context_a = manager.createContext()
+
+        assert context_a.managerState is None
+        mock_manager_interface.mock.createState.assert_not_called()
+
+    def test_when_custom_state_supported_then_returned_context_contains_state(
         self, manager, mock_manager_interface, a_host_session
     ):
+        # setup
+
+        mock_manager_interface.mock.hasCapability.return_value = True
         state_a = managerApi.ManagerStateBase()
         mock_manager_interface.mock.createState.return_value = state_a
 
+        # action
+
         context_a = manager.createContext()
 
+        # confirm
+
         assert context_a.managerState is state_a
-        assert isinstance(context_a.locale, TraitsData)
-        assert context_a.locale.traitSet() == set()
         mock_manager_interface.mock.createState.assert_called_once_with(a_host_session)
 
 
diff --git a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
index 4368711d3..a0c9c043a 100644
--- a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
+++ b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
@@ -20,12 +20,9 @@
 # pylint: disable=invalid-name,redefined-outer-name
 # pylint: disable=missing-class-docstring,missing-function-docstring
 
-from unittest.mock import Mock, call
-
 import pytest
 
-from openassetio import EntityReference, Context, errors
-from openassetio.access import RelationsAccess, DefaultEntityAccess
+from openassetio import EntityReference, Context, errors, access
 from openassetio.managerApi import (
     ManagerInterface,
     ManagerStateBase,
@@ -39,12 +36,9 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.identifier)
         assert method_introspector.is_implemented_once(ManagerInterface, "identifier")
 
-    def test_when_not_overridden_then_raises_exception(self):
-        with pytest.raises(RuntimeError) as err:
-            ManagerInterface().identifier()
-        assert (
-            str(err.value) == 'Tried to call pure virtual function "ManagerInterface::identifier"'
-        )
+    def test_is_pure_virtual(self, manager_interface, pure_virtual_error_msg):
+        with pytest.raises(RuntimeError, match=pure_virtual_error_msg.format("identifier")):
+            manager_interface.identifier()
 
 
 class Test_ManagerInterface_displayName:
@@ -52,12 +46,9 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.displayName)
         assert method_introspector.is_implemented_once(ManagerInterface, "displayName")
 
-    def test_when_not_overridden_then_raises_exception(self):
-        with pytest.raises(RuntimeError) as err:
-            ManagerInterface().displayName()
-        assert (
-            str(err.value) == 'Tried to call pure virtual function "ManagerInterface::displayName"'
-        )
+    def test_is_pure_virtual(self, manager_interface, pure_virtual_error_msg):
+        with pytest.raises(RuntimeError, match=pure_virtual_error_msg.format("displayName")):
+            manager_interface.displayName()
 
 
 class Test_ManagerInterface_info:
@@ -134,16 +125,80 @@ def test_names_indices_match_constants(self):
         )
 
 
-class Test_ManagerInterface_updateTerminology:
+class Test_ManagerInterface_hasCapability:
     def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.updateTerminology)
         assert method_introspector.is_implemented_once(ManagerInterface, "updateTerminology")
 
-    def test_when_given_dict_then_returns_same_dict(self, a_host_session):
-        terms = {"aTermKey🔥 ": "aTermValue🎖️", "aSecondTermKey": "aSecondTermValue"}
-        return_terms = ManagerInterface().updateTerminology(terms, a_host_session)
-        assert terms == return_terms
-        assert terms is not return_terms
+    def test_is_pure_virtual(self, manager_interface, pure_virtual_error_msg):
+        with pytest.raises(RuntimeError, match=pure_virtual_error_msg.format("hasCapability")):
+            manager_interface.hasCapability(ManagerInterface.Capability.kManagementPolicyQueries)
+
+
+class Test_ManagerInterface_settings:
+    def test_when_not_overridden_then_returns_empty_dict(self, manager_interface, a_host_session):
+        settings = manager_interface.settings(a_host_session)
+
+        assert isinstance(settings, dict)
+        assert settings == {}
+
+
+class Test_ManagerInterface_managementPolicy:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, a_context, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "managementPolicy", "managementPolicyQueries"
+            ),
+        ):
+            manager_interface.managementPolicy(
+                [set()], access.PolicyAccess.kRead, a_context, a_host_session
+            )
+
+
+class Test_ManagerInterface_isEntityReferenceString:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "isEntityReferenceString", "entityReferenceIdentification"
+            ),
+        ):
+            manager_interface.isEntityReferenceString("", a_host_session)
+
+
+class Test_ManagerInterface_initialize:
+    def test_when_settings_not_provided_then_default_implementation_ok(
+        self, manager_interface, a_host_session
+    ):
+        # Lack of exception means all good.
+        manager_interface.initialize({}, a_host_session)
+
+    def test_when_settings_provided_then_default_implementation_raises(
+        self, manager_interface, a_host_session
+    ):
+        expected_error_message = (
+            "Settings provided but are not supported. "
+            "The initialize method has not been implemented by the manager."
+        )
+
+        with pytest.raises(errors.InputValidationException, match=expected_error_message):
+            manager_interface.initialize({"some": "setting"}, a_host_session)
+
+
+class Test_ManagerInterface_updateTerminology:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("updateTerminology", "customTerminology"),
+        ):
+            manager_interface.updateTerminology({}, a_host_session)
 
 
 class Test_ManagerInterface_flushCaches:
@@ -160,8 +215,14 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.createState)
         assert method_introspector.is_implemented_once(ManagerInterface, "createState")
 
-    def test_default_implementation_returns_none(self, a_host_session):
-        assert ManagerInterface().createState(a_host_session) is None
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("createState", "statefulContexts"),
+        ):
+            manager_interface.createState(a_host_session)
 
 
 class Test_ManagerInterface_createChildState:
@@ -169,9 +230,14 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.createChildState)
         assert method_introspector.is_implemented_once(ManagerInterface, "createChildState")
 
-    def test_default_implementation_raises_NotImplementedException(self, a_host_session):
-        with pytest.raises(errors.NotImplementedException):
-            ManagerInterface().createChildState(ManagerStateBase(), a_host_session)
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("createChildState", "statefulContexts"),
+        ):
+            manager_interface.createChildState(ManagerStateBase(), a_host_session)
 
     def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
         with pytest.raises(TypeError):
@@ -187,14 +253,21 @@ def test_method_defined_in_cpp(self, method_introspector):
             ManagerInterface, "persistenceTokenForState"
         )
 
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "persistenceTokenForState", "statefulContexts"
+            ),
+        ):
+            manager_interface.persistenceTokenForState(ManagerStateBase(), a_host_session)
+
     def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
         with pytest.raises(TypeError):
             ManagerInterface().persistenceTokenForState(None, a_host_session)
 
-    def test_default_implementation_raises_NotImplementedException(self, a_host_session):
-        with pytest.raises(errors.NotImplementedException):
-            ManagerInterface().persistenceTokenForState(ManagerStateBase(), a_host_session)
-
 
 class Test_ManagerInterface_stateFromPersistenceToken:
     def test_method_defined_in_cpp(self, method_introspector):
@@ -205,13 +278,20 @@ def test_method_defined_in_cpp(self, method_introspector):
             ManagerInterface, "stateFromPersistenceToken"
         )
 
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_host_session, unimplemented_method_error_msg
+    ):
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "stateFromPersistenceToken", "statefulContexts"
+            ),
+        ):
+            manager_interface.stateFromPersistenceToken("", a_host_session)
+
     def test_when_none_is_supplied_then_TypeError_is_raised(self, a_host_session):
         with pytest.raises(TypeError):
-            ManagerInterface().createChildState(None, a_host_session)
-
-    def test_default_implementation_raises_NotImplementedException(self, a_host_session):
-        with pytest.raises(errors.NotImplementedException):
-            ManagerInterface().stateFromPersistenceToken("", a_host_session)
+            ManagerInterface().stateFromPersistenceToken(None, a_host_session)
 
 
 class Test_ManagerInterface_defaultEntityReference:
@@ -221,24 +301,51 @@ def test_method_defined_in_cpp(self, method_introspector):
         )
         assert method_introspector.is_implemented_once(ManagerInterface, "defaultEntityReference")
 
-    def test_gives_empty_value_for_all_trait_sets(self, manager_interface, a_host_session):
-        success_callback = Mock()
-        error_callback = Mock()
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "defaultEntityReference", "defaultEntityReferences"
+            ),
+        ):
+            manager_interface.defaultEntityReference(
+                [], access.DefaultEntityAccess.kRead, a_context, a_host_session, fail, fail
+            )
+
 
-        traitsSets = [set(), set()]
+class Test_ManagerInterface_entityExists:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
 
-        manager_interface.defaultEntityReference(
-            traitsSets,
-            DefaultEntityAccess.kRead,
-            Context(),
-            a_host_session,
-            success_callback,
-            error_callback,
-        )
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("entityExists", "existenceQueries"),
+        ):
+            manager_interface.entityExists([], a_context, a_host_session, fail, fail)
 
-        success_callback.assert_has_calls([call(0, None), call(1, None)])
 
-        error_callback.assert_not_called()
+class Test_ManagerInterface_resolve:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("resolve", "resolution"),
+        ):
+            manager_interface.resolve(
+                [], set(), access.ResolveAccess.kRead, a_context, a_host_session, fail, fail
+            )
 
 
 class Test_ManagerInterface_getWithRelationship:
@@ -246,72 +353,60 @@ def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.getWithRelationship)
         assert method_introspector.is_implemented_once(ManagerInterface, "getWithRelationship")
 
-    def test_returns_dummy_pager_for_each_input(self, manager_interface, a_host_session):
-        success_callback = Mock()
-        error_callback = Mock()
-
-        for refs in (
-            [],
-            [EntityReference("first")],
-            [EntityReference("second"), EntityReference("third")],
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "getWithRelationship", "relationshipQueries"
+            ),
         ):
             manager_interface.getWithRelationship(
-                refs,
+                [],
                 TraitsData(),
                 set(),
                 1,
-                RelationsAccess.kRead,
-                Context(),
+                access.RelationsAccess.kRead,
+                a_context,
                 a_host_session,
-                success_callback,
-                error_callback,
+                fail,
+                fail,
             )
 
-            error_callback.assert_not_called()
-
-            # The default pager behaviour is to return no data and
-            # report no new pages.
-            for idx, _ in enumerate(refs):
-                pager = success_callback.call_args_list[idx][0][1]
-                assert_is_default_pager(a_host_session, pager)
-
-            success_callback.reset_mock()
-
 
 class Test_ManagerInterface_getWithRelationships:
     def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(ManagerInterface.getWithRelationships)
         assert method_introspector.is_implemented_once(ManagerInterface, "getWithRelationships")
 
-    def test_returns_dummy_pager_for_each_input(self, manager_interface, a_host_session):
-        success_callback = Mock()
-        error_callback = Mock()
-
-        for rels in (
-            [],
-            [TraitsData()],
-            [TraitsData(), TraitsData()],
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format(
+                "getWithRelationships", "relationshipQueries"
+            ),
         ):
             manager_interface.getWithRelationships(
                 EntityReference(""),
-                rels,
+                [],
                 set(),
                 1,
-                RelationsAccess.kRead,
-                Context(),
+                access.RelationsAccess.kRead,
+                a_context,
                 a_host_session,
-                success_callback,
-                error_callback,
+                fail,
+                fail,
             )
 
-            error_callback.assert_not_called()
-
-            for idx, _ in enumerate(rels):
-                pager = success_callback.call_args_list[idx][0][1]
-                assert_is_default_pager(a_host_session, pager)
-
-            success_callback.reset_mock()
-
 
 def assert_is_default_pager(a_host_session, pager):
     # The default pager behaviour is to return no data and
@@ -352,6 +447,56 @@ def test_is_entity_reference_string_is_not_called(self, mock_manager_interface):
         mock_manager_interface.mock.isEntityReferenceString.assert_not_called()
 
 
+class Test_ManagerInterface_preflight:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("preflight", "publishing"),
+        ):
+            manager_interface.preflight(
+                [], [], access.PublishingAccess.kWrite, a_context, a_host_session, fail, fail
+            )
+
+
+class Test_ManagerInterface_register:
+    def test_default_implementation_raises_NotImplementedException(
+        self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
+    ):
+        def fail(*_):
+            pytest.fail("No callbacks should be called")
+
+        with pytest.raises(
+            errors.NotImplementedException,
+            match=unimplemented_method_error_msg.format("register_", "publishing"),
+        ):
+            manager_interface.register(
+                [], [], access.PublishingAccess.kWrite, a_context, a_host_session, fail, fail
+            )
+
+
 @pytest.fixture
 def manager_interface():
     return ManagerInterface()
+
+
+@pytest.fixture
+def a_context():
+    return Context()
+
+
+@pytest.fixture
+def pure_virtual_error_msg():
+    return 'Tried to call pure virtual function "ManagerInterface::{}"'
+
+
+@pytest.fixture
+def unimplemented_method_error_msg():
+    return (
+        "The '{}' method has not been implemented by the manager. "
+        "Check manager capability for {} by calling `manager.hasCapability`"
+    )
diff --git a/src/openassetio-python/tests/package/test/manager/resources/plugins/StubManager.py b/src/openassetio-python/tests/package/test/manager/resources/plugins/StubManager.py
index b3e7553cc..54e9054cf 100644
--- a/src/openassetio-python/tests/package/test/manager/resources/plugins/StubManager.py
+++ b/src/openassetio-python/tests/package/test/manager/resources/plugins/StubManager.py
@@ -58,10 +58,19 @@ def initialize(self, managerSettings, hostSession):
         self.__settings = managerSettings
         self.__info["host_identifier"] = hostSession.host().identifier()
 
+    def hasCapability(self, capability):
+        return capability in (
+            ManagerInterface.Capability.kEntityReferenceIdentification,
+            ManagerInterface.Capability.kManagementPolicyQueries,
+        )
+
     def managementPolicy(self, traitSets, access, context, hostSession):
         # pylint: disable=unused-argument
         return [TraitsData() for _ in traitSets]
 
+    def isEntityReferenceString(self, _):
+        return False
+
 
 class StubManagerPlugin(PythonPluginSystemManagerPlugin):
     """