Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Add `Manager.entityTraits` method
# What

Add a method to `ManagerInterface` that allows introspection of the notional "type" of the entity referenced by any given entity reference. ie the trait set that describes - or effectively constraints - the qualities it possess or must posess, regardless of what property values the manager supports for `resolve` or `register`.

# Why

In order to support generic import style workflows (eg: retrieving an entity reference from a drop event), it is necessary for a host to determine the characteristics of an entity. 

This method allows the appropriate reader/loader/etc. to be created. At present, it is not possible to determine an entities traits from an entity ref.

When publishing it also allows introspection of the expected traits so behaviour can be adapted to ensure successful operation.

Unlike `managementPolicy` the resulting trait set should contain all known traits that represent qualities of the entity in question (or future entity), it should not omit any traits that have no properties, or the manager is unable to provide data for.

Passing the result of this through `managmentPolicy` would tell you which traits a manager may be able to populate/persist.

## ACs

- `ManagerInterface::entityTraits(const EntityReferences& entityReferences, TraitsAccess access, const ContextPtr& context, <callbacks/session>);`
  - `TraitsAccess` would habe `kRead` and `kWrite`
  - `using EntityTraitsSuccessCallback = function::<void(size_t, TraitSet)>;`
  - empty trait set when not known/applicable (not error as its a legitimate case, eg new asset with no type constraints)
  - error when :
     - kRead: entity doesn't exist
     - kWrite: entity is read-only or publishing is not allowed to that reference
     - all: malformed reference
  - `apiCompliance` checks for edge cases.
  - Simple example of drag-drop/paste of entity reference (ie via mime-drop or clipboard read) in `Examples.dox`
   
- Required as part of the `kEntityTraitIntrospection` interface-only capability. Validated along with the other required interface traits.

## Notes

First task is to write/review the docs to check were happy with the final definition before implementation.
[Docs] Migrate API intro page
Part of #29, this migrates the API introduction page. Many of the `@ref`s are dependent on #30 - so ignore where they have expanded to a slightly less readable presentation.

@fn-yves You mentioned a diagram yesterday - this is a revised version of the old one to include the main API namespaces. We should re-group once this, and the examples are in to see what else we might need.


Discussion / hints:
@tanant - your mention of drag and drop the other day reminded me that we also need something like this.
~Considering #238, this may be better as `entityTraits` to avoid ambiguity as to whether the trait properties should be filled in. It also may make sense to make `register` should also take a `TraitCollection`, or not attributes, when we look at #4.~

~The same applies to `managementPolicy`, should this be fully filterable based on attributes, or just partitioned by traits, to avoid confusion.~ Stale comment after API changes in the interim.
Rebased on to `main` to pick up #30, this should fix the broken refs in `MainPage.dox`.
[fix] The `BASEEXCEPTION CLASS REFERENCE` page is not correctly formatted.

See auto-generated page `html/classopenassetio_1_1exceptions_1_1_base_exception.html`.

The diagram is not completely visible (the right part is truncated).

See auto-generated page `html/graph_legend.html` for legend instructions.


> [fix] The `BASEEXCEPTION CLASS REFERENCE` page is not correctly formatted.
> 
> See auto-generated page `html/classopenassetio_1_1exceptions_1_1_base_exception.html`.
> 
> The diagram is not completely visible (the right part is truncated).
> 
> See auto-generated page `html/graph_legend.html` for legend instructions.

Good spot, thanks @fn-yves - we probably have a bunch of Doxygen issues at the moment - unrelated to the main page specifically, I've made #34 so we can track these, and pick them up later. 
LGTM. Feel free to squash & merge

Fix patch:
diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml
index 9e6298241..5669f62af 100644
--- a/.github/workflows/integrations.yml
+++ b/.github/workflows/integrations.yml
@@ -67,21 +67,22 @@ jobs:
           repository: OpenAssetIO/OpenAssetIO-Manager-BAL
           path: external/BAL
 
-      - name: Test BAL
-        run: |
-          python -m pip install external/BAL
-          # We can't install BAL's requirements.txt as this includes
-          # openassetio... we really need to sort
-          # https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL/issues/72
-          python -m pip install openassetio-mediacreation
-          python -m pip install -r external/BAL/tests/requirements.txt
-          python -m pytest -v external/BAL/tests
-
-      - name: Test Simple Resolver
-        run: |
-          python -m pytest -v examples/host/simpleResolver
-        env:
-          OPENASSETIO_PLUGIN_PATH: ${{ github.workspace }}/external/BAL/plugin
+      # Disabled pending https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL/pull/93
+#      - name: Test BAL
+#        run: |
+#          python -m pip install external/BAL
+#          # We can't install BAL's requirements.txt as this includes
+#          # openassetio... we really need to sort
+#          # https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL/issues/72
+#          python -m pip install openassetio-mediacreation
+#          python -m pip install -r external/BAL/tests/requirements.txt
+#          python -m pytest -v external/BAL/tests
+#
+#      - name: Test Simple Resolver
+#        run: |
+#          python -m pytest -v examples/host/simpleResolver
+#        env:
+#          OPENASSETIO_PLUGIN_PATH: ${{ github.workspace }}/external/BAL/plugin
 
       - name: Checkout TraitGen
         uses: actions/checkout@v4
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c6dec46d3..9bf69ee2a 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -4,6 +4,19 @@ Release Notes
 v1.0.0-beta.x.x
 ---------------
 
+### Breaking changes
+
+- Added new required core API method
+  `Manager`/`ManagerInterface.entityTraits`, which can be used to
+  determine the traits that an existing entity has, or that a new entity
+  must have, for a given entity reference.
+  [#31](https://github.com/OpenAssetIO/OpenAssetIO/issues/31)
+
+- Access policy keyword argument names in the Python bindings for
+  `ManagerInterface` has changed  to `"resolveAccess"` for `resolve` and
+  `"policyAccess"` for `managementPolicy`, in order to conform to the
+  corresponding C++ argument names.
+
 ### New Features
 
 - Propagate `OpenAssetIOException`-derived Python exceptions as a
diff --git a/doc/doxygen/src/Examples.dox b/doc/doxygen/src/Examples.dox
index 074ecab66..cc2dfc229 100644
--- a/doc/doxygen/src/Examples.dox
+++ b/doc/doxygen/src/Examples.dox
@@ -174,6 +174,60 @@
  * url = LocatableContentTrait(resolved_asset).getLocation()  # May be None
  * @endcode
  *
+ * @section examples_inspecting_a_reference Inspecting a reference
+ *
+ * This example shows how an entity reference of unknown provenance can
+ * be inspected to determine the qualities of the entity it points to.
+ *
+ * Let's say we're an audio application written in Qt, and someone has
+ * just dragged and dropped a line of text into the application. We want
+ * to know if the text is an entity reference, and if so, does it point
+ * to an audio clip.
+ *
+ * @code{.py}
+ * from openassetio.access import EntityTraitsAccess
+ * from openassetio_mediacreation.traits.content import LocatableContentTrait
+ * from openassetio_mediacreation.specifications.audio import SampledAudioResource
+ *
+ * class MyAudioApp(QMainWindow):
+ *
+ *   def __init__(self, manager):
+ *     super().__init__()
+ *     self.__manager = manager
+ *     self.__context = manager.createContext()
+ *     self.setAcceptDrops(True)
+ *     # [... Other Qt setup]
+ *
+ *   def dragEnterEvent(self, event):
+ *     if event.mimeData().hasUrls():
+ *       event.acceptProposedAction()
+ *
+ *   def dropEvent(self, event):
+ *     if not event.mimeData().hasUrls():
+ *       return
+ *     uri = event.mimeData().urls().first().toString()
+ *     ref = self.__manager.createEntityReferenceIfValid(uri)
+ *     if ref is None:
+ *       return
+ *
+ *     entity_trait_set = self.__manager.entityTraits(
+ *       ref, EntityTraitsAccess.kRead, self.__context)
+ *
+ *     # Ignore this drop if the entity is not of the correct type for
+ *     # this application.
+ *     if not SampledAudioResource.kTraitSet <= entity_trait_set
+ *       return
+ *
+ *     resolved_asset = manager.resolve(
+ *        entity_reference, {LocatableContentTrait.kId},
+ *        ResolveAccess.kRead, context)
+ *     url = LocatableContentTrait(resolved_asset).getLocation()
+ *
+ *     self.load_audio_from_url(url)
+ *
+ *  # [... Other application setup]
+ * @endcode
+ *
  * @section example_discovering_capability Discovering capability
  *
  * In the previous example, we saw how to resolve a specific trait for
diff --git a/doc/doxygen/src/Glossary.dox b/doc/doxygen/src/Glossary.dox
index 9035b3f73..764d69a12 100644
--- a/doc/doxygen/src/Glossary.dox
+++ b/doc/doxygen/src/Glossary.dox
@@ -553,7 +553,6 @@
  *
  * @see @ref entities_traits_and_specifications
  *
- *
  * @section trait_set Trait Set
  *
  * A trait set is the logical conjunction of one or more @ref trait
@@ -571,4 +570,7 @@
  * traits it is capable of storing or recalling property data for.
  *
  * @see @ref entities_traits_and_specifications
+ * @see @fqref{hostApi.Manager.entityTraits} "Manager.entityTraits" /
+ * @fqref{managerApi.ManagerInterface.entityTraits}
+ * "ManagerInterface.entityTraits"
  */
diff --git a/src/openassetio-core/include/openassetio/access.hpp b/src/openassetio-core/include/openassetio/access.hpp
index 095bc97fd..663d55259 100644
--- a/src/openassetio-core/include/openassetio/access.hpp
+++ b/src/openassetio-core/include/openassetio/access.hpp
@@ -73,6 +73,27 @@ enum class ResolveAccess : std::underlying_type_t<internal::access::Access> {
   kWrite = internal::access::Access::kWrite,
 };
 
+/**
+ * Access pattern for entity trait set queries.
+ */
+enum class EntityTraitsAccess : std::underlying_type_t<internal::access::Access> {
+  /**
+   * Used to query the full trait set of an existing entity.
+   *
+   * For example, when an entity is known to exist, but is of unknown
+   * classification.
+   */
+  kRead = internal::access::Access::kRead,
+  /**
+   * Used to query the minimal trait set that must be specified when
+   * publishing to a particular entity reference.
+   *
+   * For example, when validating that a user-supplied entity reference
+   * is appropriate for a publishing operation.
+   */
+  kWrite = internal::access::Access::kWrite,
+};
+
 /**
  * Access pattern for @ref publish "publishing".
  */
diff --git a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
index e51b94a2d..cc610b8b4 100644
--- a/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
+++ b/src/openassetio-core/include/openassetio/hostApi/Manager.hpp
@@ -390,6 +390,10 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * know in advance if you can expect the configured manager to be able
    * to provide data you may require.
    *
+   * This method gives the global policy for how the manager wishes to
+   * interact with certain categories of entity. See @ref entityTraits
+   * for entity-specific introspection.
+   *
    * @note Because traits are specific to any given application of the
    * API, please refer to the documentation for any relevant companion
    * project(s) that provide traits and specifications for your specific
@@ -609,11 +613,10 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    */
 
   /**
-   * @name Entity Reference inspection
+   * @name Entity Reference Inspection
    *
-   * Because of the nature of an @ref entity_reference, it is often
-   * necessary to determine if some working string is actually an @ref
-   * entity_reference or not, to ensure it is handled correctly.
+   * Functionality for validating and creating entity references, and
+   * the existence or kind of entity that they point to.
    *
    * @{
    */
@@ -754,6 +757,72 @@ class OPENASSETIO_CORE_EXPORT Manager final {
   void entityExists(const EntityReferences& entityReferences, const ContextConstPtr& context,
                     const ExistsSuccessCallback& successCallback,
                     const BatchElementErrorCallback& errorCallback);
+
+  /**
+   * Callback signature used for a successful entity trait set query.
+   */
+  using EntityTraitsSuccessCallback = std::function<void(std::size_t, trait::TraitSet)>;
+
+  /**
+   * Retrieve the @ref trait_set of one or more @ref entity "entities".
+   *
+   * For example, this may be used to validate that a user-provided
+   * entity reference is appropriate for an operation.
+   *
+   * The trait set returned (via callback) for each @ref
+   * entity_reference varies according to the @p entityTraitsAccess
+   * access mode.
+   *
+   * If @ref access.EntityTraitsAccess.kRead "kRead" is given, the
+   * response will be an exhaustive trait set for the entity. This may
+   * also include traits whose properties the manager is not capable of
+   * @ref resolve "resolving", in order to aid categorisation. If an
+   * entity does not exist, then the error callback will be invoked
+   * using the @ref errors.BatchElementError.ErrorCode.kEntityResolutionError
+   * "kEntityResolutionError" code.
+   *
+   * If @ref access.EntityTraitsAccess.kWrite "kWrite" is given, the
+   * response will be the minimal trait set required to categorize the
+   * entity during publishing.  This may include traits whose properties
+   * the manager is not capable of @ref register_ "persisting". If an
+   * entity is read-only, the error callback will be invoked using the
+   * @ref errors.BatchElementError.ErrorCode.kEntityAccessError
+   * "kEntityAccessError" code.
+   *
+   * Since the trait set will include all relevant traits for the access
+   * mode, not just those with properties that the manager can
+   * supply/store, call @ref managementPolicy to determine which of
+   * those traits hold properties that can be @ref resolve "resolved" or
+   * @ref register_ "persisted".
+   *
+   * An empty trait set is a valid response, for example if the entity
+   * is a new asset with no type constraints.
+   *
+   * @param entityReferences Entity references to query.
+   *
+   * @param entityTraitsAccess The intended usage of the data.
+   *
+   * @param context The calling context.
+   *
+   * @param successCallback Callback that will be called for each trait
+   * set retrieved for the entity references. It will be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with its @ref trait_set. The callback will be called on the
+   * same thread that initiated the call to `entityTraits`.
+   *
+   * @param errorCallback Callback that will be called for each failure.
+   * It will be given 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 will be called on the same thread that initiated the call
+   * to `entityTraits`.
+   */
+  virtual void entityTraits(const EntityReferences& entityReferences,
+                            access::EntityTraitsAccess entityTraitsAccess,
+                            const ContextConstPtr& context,
+                            const EntityTraitsSuccessCallback& successCallback,
+                            const BatchElementErrorCallback& errorCallback);
   /**
    * @}
    */
@@ -793,6 +862,12 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * which specific traits any given manager supports resolving property
    * data for.
    *
+   * To determine the @ref trait_set for a particular entity, use @ref
+   * entityTraits. Note that this will give a complete trait set,
+   * including traits that solely aid classification and whose
+   * properties cannot be resolved. See the docs for @ref entityTraits
+   * for more information.
+   *
    * @note @fqref{EntityReference} "EntityReference" objects _must_ be
    * constructed using either
    * @fqref{hostApi.Manager.createEntityReference}
@@ -803,11 +878,11 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * @fqref{hostApi.Manager.isEntityReferenceString}
    * "isEntityReferenceString" first.
    *
-   *  Note that any properties that are defined as being a URL will be
-   *  URL encoded. If it is expected that trait properties may contain
-   *  substitution tokens or similar, their convention and behaviour
-   *  will be defined in the documentation for the respective trait.
-   *  Consult the originating project of the trait for more information.
+   * Note that any properties that are defined as being a URL will be
+   * URL encoded. If it is expected that trait properties may contain
+   * substitution tokens or similar, their convention and behaviour
+   * will be defined in the documentation for the respective trait.
+   * 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
@@ -1423,6 +1498,12 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * entity reference, then that element will error. See @ref
    * glossary_preflight "glossary entry" for details.
    *
+   * The @ref entityTraits method may be used to determine the minimal
+   * @ref trait_set required for publishing. Note that the manager may
+   * not persist all trait properties in the given set, they may be
+   * required solely for classification. See @ref entityTraits docs for
+   * more information.
+   *
    * @warning The working @ref entity_reference returned by this
    * method should *always* be used in place of the original
    * reference supplied to `preflight` for resolves prior to
@@ -1721,6 +1802,12 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * be ignored. The full @ref trait_set will always be stored though,
    * to facilitate future identification.
    *
+   * The @ref entityTraits method may be used to determine the minimal
+   * @ref trait_set required for publishing. Note that the manager may
+   * not persist all trait properties in the given set, they may be
+   * required solely for classification. See @ref entityTraits docs for
+   * more information.
+   *
    * As each @ref entity_reference has (ultimately) come from the
    * manager (either in response to delegation of UI/etc... or as a
    * return from another call), then it can be assumed that the
@@ -1741,10 +1828,6 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * `ShotSpecification` entity to without error. The resulting entity
    * reference should then reference the newly created Shot.
    *
-   * @note All supplied trait::TraitsDatas should have the same trait
-   * sets. If you wish to register different "types" of entity, they
-   * need to be registered in separate calls.
-   *
    * @warning When registering traits that contain URLs or file paths
    * (for example the MediaCreation LocatableContent trait), it should
    * never be assumed that the resulting @ref entity_reference will
@@ -1784,8 +1867,6 @@ class OPENASSETIO_CORE_EXPORT Manager final {
    * "ErrorCodes"). The callback will be called on the same thread
    * that initiated the call to `register`.
    *
-   * @return None
-   *
    * @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
diff --git a/src/openassetio-core/include/openassetio/internal.hpp b/src/openassetio-core/include/openassetio/internal.hpp
index df0550338..312741505 100644
--- a/src/openassetio-core/include/openassetio/internal.hpp
+++ b/src/openassetio-core/include/openassetio/internal.hpp
@@ -37,7 +37,8 @@ enum Capability : std::size_t {
   kPublishing,
   kRelationshipQueries,
   kExistenceQueries,
-  kDefaultEntityReferences
+  kDefaultEntityReferences,
+  kEntityTraitIntrospection
 };
 }  // namespace capability::manager
 
diff --git a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
index f42dd9c53..77e163807 100644
--- a/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
+++ b/src/openassetio-core/include/openassetio/managerApi/ManagerInterface.hpp
@@ -274,6 +274,22 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
      */
     kManagementPolicyQueries = internal::capability::manager::Capability::kManagementPolicyQueries,
 
+    /**
+     * Manager can be queried for the @ref trait "traits" of a given
+     * entity.
+     *
+     * @warning Support for this capability is required by all managers.
+     * In situations where plugins are implemented as multiple component
+     * plugins (e.g. Python and C++) at least one of the component
+     * plugins must implement this capability.
+     *
+     * This capability means the manager implements the following
+     * methods:
+     * - @ref entityTraits
+     */
+    kEntityTraitIntrospection =
+        internal::capability::manager::Capability::kEntityTraitIntrospection,
+
     /**
      * Manager makes use of the context to persist custom state for
      * performance reasons or otherwise.
@@ -347,7 +363,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
      * methods:
      * - @ref defaultEntityReference
      */
-    kDefaultEntityReferences = internal::capability::manager::Capability::kDefaultEntityReferences
+    kDefaultEntityReferences = internal::capability::manager::Capability::kDefaultEntityReferences,
   };
 
   /// Mapping of ManagerCapability enum value to human-readable name.
@@ -359,7 +375,8 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
                                                                 "publishing",
                                                                 "relationshipQueries",
                                                                 "existenceQueries",
-                                                                "defaultEntityReferences"};
+                                                                "defaultEntityReferences",
+                                                                "entityTraitIntrospection"};
 
   /**
    * Query the manager as to which capabilities it implements.
@@ -572,6 +589,10 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * the API or presenting asset-centric elements of a workflow to the
    * user.
    *
+   * This method gives the global policy for how you wish to interact
+   * with certain categories of entity. See @ref entityTraits for
+   * entity-specific introspection.
+   *
    * @note Because traits are specific to any given application of the
    * API, please refer to the documentation for any relevant companion
    * project(s) that provide traits and specifications for your specific
@@ -797,11 +818,10 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    */
 
   /**
-   * @name Entity Reference inspection
+   * @name Entity Reference Inspection
    *
-   * Because of the nature of an @ref entity_reference, it is often
-   * necessary to determine if some working string is actually an @ref
-   * entity_reference or not, to ensure it is handled correctly.
+   * Functionality for validating entity references, and the existence
+   * or kind of entity that they point to.
    *
    * @{
    */
@@ -921,6 +941,80 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
                             const ExistsSuccessCallback& successCallback,
                             const BatchElementErrorCallback& errorCallback);
 
+  /**
+   * Callback signature used for a successful entity trait set query.
+   */
+  using EntityTraitsSuccessCallback = std::function<void(std::size_t, trait::TraitSet)>;
+
+  /**
+   * Provides the host with the @ref trait_set of one or more @ref
+   * entity "entities".
+   *
+   * For example, a host may use this to validate that a user-provided
+   * entity reference is appropriate for an operation.
+   *
+   * The trait set returned (via callback) for each @ref
+   * entity_reference should vary according to the @p entityTraitsAccess
+   * access mode.
+   *
+   * If @ref access.EntityTraitsAccess.kRead "kRead" is given, respond
+   * with the exhaustive trait set of the entity. Include traits whose
+   * properties you are not capable of @ref resolve "resolving" , but
+   * that nevertheless are used to categorize the entity. If an entity
+   * does not exist, then call the error callback using the @ref
+   * errors.BatchElementError.ErrorCode.kEntityResolutionError
+   * "kEntityResolutionError" code.
+   *
+   * If @ref access.EntityTraitsAccess.kWrite "kWrite" is given, respond
+   * with the minimal trait set required to publish to the entity
+   * reference. Include traits whose properties you are not capable of
+   * @ref register_ "persisting", but which are required for
+   * categorization. If an entity is read-only, then call the error
+   * callback using the @ref errors.BatchElementError.ErrorCode.kEntityAccessError
+   * "kEntityAccessError" code.
+   *
+   * Ensure your @ref managementPolicy can be used to determine which
+   * traits hold properties that can be @ref resolve "resolved" or @ref
+   * register_ "persisted".
+   *
+   * An empty trait set is a valid response, for example if the entity
+   * is a new asset with no type constraints.
+   *
+   * @param entityReferences Entity references to query.
+   *
+   * @param entityTraitsAccess The host's intended usage of the data.
+   *
+   * @param context The calling context.
+   *
+   * @param hostSession The host session that maps to the caller, this
+   * should be used for all logging and provides access to the Host
+   * object representing the process that initiated the API session.
+   *
+   * @param successCallback Callback that must be called for each trait
+   * set retrieved for the entity references. It should be given the
+   * corresponding index of the entity reference in @p entityReferences
+   * along with its @ref trait_set. The callback must be called on the
+   * same thread that initiated the call to `entityTraits`.
+   *
+   * @param errorCallback Callback that must be called for each failure.
+   * It should be given 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 `entityTraits`.
+   *
+   * @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.kEntityTraitIntrospection
+   */
+  virtual void entityTraits(const EntityReferences& entityReferences,
+                            access::EntityTraitsAccess entityTraitsAccess,
+                            const ContextConstPtr& context, const HostSessionPtr& hostSession,
+                            const EntityTraitsSuccessCallback& successCallback,
+                            const BatchElementErrorCallback& errorCallback);
   /**
    * @}
    */
@@ -954,9 +1048,15 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * have no properties, or are not supported by your implementation,
    * should be ignored and not imbued to the result. Your
    * implementation of @ref managementPolicy when called with a read
-   * @ref Context should accurately reflect which traits you understand
+   * access mode should accurately reflect which traits you understand
    * and are capable of resolving data for here.
    *
+   * The @ref entityTraits method may be called by hosts to determine
+   * the @ref trait_set of an entity. It is not necessary to be able to
+   * resolve the properties for all of an entity's traits - they may be
+   * used solely to aid classification. See docs for @ref entityTraits
+   * for more information.
+   *
    * @warning See the documentation for each respective trait as to
    * which properties are considered required. It is the responsibility
    * of the caller to handle optional property values being missing in a
@@ -968,9 +1068,9 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * their specifics. Don't forget to add a scheme and URL encode any
    * paths that are stored in properties defined as holding a URL.
    *
-   * The Context should also be carefully considered to ensure that the
-   * access does not violate any rules of the system - for example,
-   * resolving an existing entity reference for write.
+   * The @p resolveAccess should also be carefully considered to ensure
+   * that it does not violate any rules of the system - for example,
+   * resolving a read-only entity reference for write.
    *
    * The supplied entity references will have already been validated
    * as relevant to this manager (via
@@ -1422,6 +1522,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * eventual @ref register_, then error that element with an
    * appropriate @fqref{errors.BatchElementError.ErrorCode}.
    *
+   * A host may use @ref entityTraits to determine the minimal @ref
+   * trait_set required for publishing to an entity reference. Note that
+   * it is not necessary to persist the properties of all of these
+   * traits, they may solely aid in classification. See @ref
+   * entityTraits docs for more information.
+   *
    * @param entityReferences An @ref entity_reference for each entity
    * that it is desired to publish the forthcoming data to. See the
    * notes in the API documentation for the specifics of this.
@@ -1519,7 +1625,7 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * the supplied TraitsData for each reference is persisted. This forms
    * the entity's 'type'. It is also a requirement that the properties
    * of any traits indicated as supported by your response to a
-   * `managementPolicy` query for write context are persisted.
+   * `managementPolicy` query with write access are persisted.
    *
    * If the supplied @ref trait_set is missing required traits for any
    * of the provided references (maybe they are mismatched with the
@@ -1527,6 +1633,12 @@ class OPENASSETIO_CORE_EXPORT ManagerInterface {
    * with an appropriate @fqref{errors.BatchElementError.ErrorCode}
    * "ErrorCode".
    *
+   * A host may use @ref entityTraits to determine the minimal @ref
+   * trait_set required for publishing to an entity reference. Note that
+   * it is not necessary to persist the properties of all of these
+   * traits, they may be used solely to aid in classification. See @ref
+   * entityTraits docs for more information.
+   *
    * @param entityReferences  The @ref entity_reference of each entity
    * to register_. It is up to the manager to ensure that this is
    * meaningful, as it is most likely implementation specific. For
diff --git a/src/openassetio-core/src/hostApi/Manager.cpp b/src/openassetio-core/src/hostApi/Manager.cpp
index 6be0971d4..30a90d333 100644
--- a/src/openassetio-core/src/hostApi/Manager.cpp
+++ b/src/openassetio-core/src/hostApi/Manager.cpp
@@ -39,7 +39,9 @@ void verifyRequiredCapabilities(const managerApi::ManagerInterfacePtr &interface
 
   static constexpr std::array kRequiredCapabilities = {
       ManagerInterface::Capability::kEntityReferenceIdentification,
-      ManagerInterface::Capability::kManagementPolicyQueries};
+      ManagerInterface::Capability::kManagementPolicyQueries,
+      ManagerInterface::Capability::kEntityTraitIntrospection,
+  };
 
   std::vector<std::string> missingCapabilities;
   for (const ManagerInterface::Capability capability : kRequiredCapabilities) {
@@ -53,7 +55,7 @@ void verifyRequiredCapabilities(const managerApi::ManagerInterfacePtr &interface
     return;
   }
 
-  std::string msg =
+  const std::string msg =
       fmt::format("Manager implementation for '{}' does not support the required capabilities: {}",
                   interface->identifier(), fmt::join(missingCapabilities, ", "));
 
@@ -205,6 +207,15 @@ void Manager::entityExists(const EntityReferences &entityReferences,
                                   errorCallback);
 }
 
+void Manager::entityTraits(const EntityReferences &entityReferences,
+                           const access::EntityTraitsAccess entityTraitsAccess,
+                           const ContextConstPtr &context,
+                           const EntityTraitsSuccessCallback &successCallback,
+                           const BatchElementErrorCallback &errorCallback) {
+  managerInterface_->entityTraits(entityReferences, entityTraitsAccess, context, hostSession_,
+                                  successCallback, errorCallback);
+}
+
 void Manager::resolve(const EntityReferences &entityReferences, const trait::TraitSet &traitSet,
                       const access::ResolveAccess resolveAccess, const ContextConstPtr &context,
                       const ResolveSuccessCallback &successCallback,
diff --git a/src/openassetio-core/src/managerApi/ManagerInterface.cpp b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
index fd2e819a0..928f6345e 100644
--- a/src/openassetio-core/src/managerApi/ManagerInterface.cpp
+++ b/src/openassetio-core/src/managerApi/ManagerInterface.cpp
@@ -66,6 +66,17 @@ void ManagerInterface::entityExists(
       UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kExistenceQueries)};
 }
 
+void ManagerInterface::entityTraits(
+    [[maybe_unused]] const EntityReferences& entityReferences,
+    [[maybe_unused]] const access::EntityTraitsAccess entityTraitsAccess,
+    [[maybe_unused]] const ContextConstPtr& context,
+    [[maybe_unused]] const HostSessionPtr& hostSession,
+    [[maybe_unused]] const ManagerInterface::EntityTraitsSuccessCallback& successCallback,
+    [[maybe_unused]] const ManagerInterface::BatchElementErrorCallback& errorCallback) {
+  throw errors::NotImplementedException{
+      UNIMPLEMENTED_ERROR(ManagerInterface::Capability::kEntityTraitIntrospection)};
+}
+
 void ManagerInterface::resolve(
     [[maybe_unused]] const EntityReferences& entityReferences,
     [[maybe_unused]] const trait::TraitSet& traitSet,
diff --git a/src/openassetio-python/cmodule/src/accessBinding.cpp b/src/openassetio-python/cmodule/src/accessBinding.cpp
index 23060fbfc..975a518c9 100644
--- a/src/openassetio-python/cmodule/src/accessBinding.cpp
+++ b/src/openassetio-python/cmodule/src/accessBinding.cpp
@@ -19,6 +19,10 @@ void registerAccess(const py::module& mod) {
       .value("kRead", access::ResolveAccess::kRead)
       .value("kWrite", access::ResolveAccess::kWrite);
 
+  py::enum_<access::EntityTraitsAccess>{mod, "EntityTraitsAccess"}
+      .value("kRead", access::EntityTraitsAccess::kRead)
+      .value("kWrite", access::EntityTraitsAccess::kWrite);
+
   py::enum_<access::PublishingAccess>{mod, "PublishingAccess"}
       .value("kWrite", access::PublishingAccess::kWrite)
       .value("kCreateRelated", access::PublishingAccess::kCreateRelated);
diff --git a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
index be7273112..4cc416200 100644
--- a/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
+++ b/src/openassetio-python/cmodule/src/hostApi/ManagerBinding.cpp
@@ -100,6 +100,10 @@ void registerManager(const py::module& mod) {
       .def("entityExists", &Manager::entityExists, py::arg("entityReferences"),
            py::arg("context").none(false), py::arg("successCallback"), py::arg("errorCallback"),
            py::call_guard<py::gil_scoped_release>{})
+      .def("entityTraits", &Manager::entityTraits, py::arg("entityReferences"),
+           py::arg("entityTraitsAccess"), py::arg("context").none(false),
+           py::arg("successCallback"), py::arg("errorCallback"),
+           py::call_guard<py::gil_scoped_release>{})
       .def("hasCapability", &Manager::hasCapability, py::arg("capability"),
            py::call_guard<py::gil_scoped_release>{})
       .def("updateTerminology", &Manager::updateTerminology, py::arg("terms"),
diff --git a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
index 16814842b..31622e85d 100644
--- a/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
+++ b/src/openassetio-python/cmodule/src/managerApi/ManagerInterfaceBinding.cpp
@@ -119,6 +119,16 @@ struct PyManagerInterface : ManagerInterface {
                                   errorCallback);
   }
 
+  void entityTraits(const EntityReferences& entityReferences,
+                    const access::EntityTraitsAccess entityTraitsAccess,
+                    const ContextConstPtr& context, const HostSessionPtr& hostSession,
+                    const EntityTraitsSuccessCallback& successCallback,
+                    const BatchElementErrorCallback& errorCallback) override {
+    OPENASSETIO_PYBIND11_OVERRIDE(void, ManagerInterface, entityTraits, entityReferences,
+                                  entityTraitsAccess, context, hostSession, successCallback,
+                                  errorCallback);
+  }
+
   void defaultEntityReference(const trait::TraitSets& traitSets,
                               const access::DefaultEntityAccess defaultEntityAccess,
                               const ContextConstPtr& context, const HostSessionPtr& hostSession,
@@ -210,7 +220,8 @@ void registerManagerInterface(const py::module& mod) {
       .value("kPublishing", ManagerInterface::Capability::kPublishing)
       .value("kRelationshipQueries", ManagerInterface::Capability::kRelationshipQueries)
       .value("kExistenceQueries", ManagerInterface::Capability::kExistenceQueries)
-      .value("kDefaultEntityReferences", ManagerInterface::Capability::kDefaultEntityReferences);
+      .value("kDefaultEntityReferences", ManagerInterface::Capability::kDefaultEntityReferences)
+      .value("kEntityTraitIntrospection", ManagerInterface::Capability::kEntityTraitIntrospection);
 
   pyManagerInterface.attr("kCapabilityNames") = ManagerInterface::kCapabilityNames;
 
@@ -225,8 +236,8 @@ void registerManagerInterface(const py::module& mod) {
       .def("flushCaches", &ManagerInterface::flushCaches, py::arg("hostSession").none(false),
            py::call_guard<py::gil_scoped_release>{})
       .def("managementPolicy", &ManagerInterface::managementPolicy, py::arg("traitSets"),
-           py::arg("access"), py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::call_guard<py::gil_scoped_release>{})
+           py::arg("policyAccess"), py::arg("context").none(false),
+           py::arg("hostSession").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("createState", &ManagerInterface::createState, py::arg("hostSession").none(false),
            py::call_guard<py::gil_scoped_release>{})
       .def("createChildState", RetainCommonPyArgs::forFn<&ManagerInterface::createChildState>(),
@@ -246,14 +257,18 @@ void registerManagerInterface(const py::module& mod) {
            py::arg("context").none(false), py::arg("hostSession").none(false),
            py::arg("successCallback"), py::arg("errorCallback"),
            py::call_guard<py::gil_scoped_release>{})
+      .def("entityTraits", &ManagerInterface::entityTraits, py::arg("entityReferences"),
+           py::arg("entityTraitsAccess"), py::arg("context").none(false),
+           py::arg("hostSession").none(false), py::arg("successCallback"),
+           py::arg("errorCallback"), py::call_guard<py::gil_scoped_release>{})
       .def("hasCapability", &ManagerInterface::hasCapability, py::arg("capability"),
            py::call_guard<py::gil_scoped_release>{})
       .def("updateTerminology", &ManagerInterface::updateTerminology, py::arg("terms"),
            py::arg("hostSession").none(false), py::call_guard<py::gil_scoped_release>{})
       .def("resolve", &ManagerInterface::resolve, py::arg("entityReferences"), py::arg("traitSet"),
-           py::arg("access"), py::arg("context").none(false), py::arg("hostSession").none(false),
-           py::arg("successCallback"), py::arg("errorCallback"),
-           py::call_guard<py::gil_scoped_release>{})
+           py::arg("resolveAcess"), py::arg("context").none(false),
+           py::arg("hostSession").none(false), py::arg("successCallback"),
+           py::arg("errorCallback"), py::call_guard<py::gil_scoped_release>{})
       .def("defaultEntityReference", &ManagerInterface::defaultEntityReference,
            py::arg("traitSets"), py::arg("defaultEntityAccess"), py::arg("context").none(false),
            py::arg("hostSession").none(false), py::arg("successCallback"),
diff --git a/src/openassetio-python/package/openassetio/access.py b/src/openassetio-python/package/openassetio/access.py
index 73fa8fb9e..5c5588683 100644
--- a/src/openassetio-python/package/openassetio/access.py
+++ b/src/openassetio-python/package/openassetio/access.py
@@ -24,6 +24,7 @@
 kAccessNames = _openassetio.access.kAccessNames
 PolicyAccess = _openassetio.access.PolicyAccess
 ResolveAccess = _openassetio.access.ResolveAccess
+EntityTraitsAccess = _openassetio.access.EntityTraitsAccess
 PublishingAccess = _openassetio.access.PublishingAccess
 RelationsAccess = _openassetio.access.RelationsAccess
 DefaultEntityAccess = _openassetio.access.DefaultEntityAccess


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 3704400cb..14838e550 100644
--- a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
+++ b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
@@ -39,7 +39,13 @@
 from .harness import FixtureAugmentedTestCase
 from ... import EntityReference
 from ...errors import BatchElementError
-from ...access import PolicyAccess, ResolveAccess, RelationsAccess, PublishingAccess
+from ...access import (
+    PolicyAccess,
+    ResolveAccess,
+    RelationsAccess,
+    PublishingAccess,
+    EntityTraitsAccess,
+)
 from ...trait import TraitsData
 
 
@@ -329,6 +335,152 @@ def test_when_querying_malformed_reference_then_malformed_reference_error_is_ret
         self.assertEqual(error.message, expected_error_message)
 
 
+class Test_entityTraits(FixtureAugmentedTestCase):
+    """
+    Check plugin's implementation of
+    managerApi.ManagerInterface.entityTraits.
+    """
+
+    def test_when_querying_malformed_reference_then_malformed_reference_error_is_returned(self):
+        a_malformed_reference = self.requireEntityReferenceFixture(
+            "a_malformed_reference", skipTestIfMissing=True
+        )
+        expected_error_message = self.requireFixture("expected_error_message")
+        self.__assert_error(
+            a_malformed_reference,
+            BatchElementError.ErrorCode.kMalformedEntityReference,
+            expected_error_message,
+        )
+
+    def test_when_querying_missing_reference_for_read_then_resolution_error_is_returned(self):
+        a_missing_reference = self.requireEntityReferenceFixture(
+            "a_reference_to_a_missing_entity", skipTestIfMissing=True
+        )
+        expected_error_message = self.requireFixture("expected_error_message")
+        self.__assert_error(
+            a_missing_reference,
+            BatchElementError.ErrorCode.kEntityResolutionError,
+            expected_error_message,
+        )
+
+    def test_when_read_only_entity_queried_for_write_then_access_error_is_returned(self):
+        a_readonly_reference = self.requireEntityReferenceFixture(
+            "a_reference_to_a_readonly_entity", skipTestIfMissing=True
+        )
+        expected_error_message = self.requireFixture("expected_error_message")
+        self.__assert_error(
+            a_readonly_reference,
+            BatchElementError.ErrorCode.kEntityAccessError,
+            expected_error_message,
+            entity_traits_access=EntityTraitsAccess.kWrite,
+        )
+
+    def test_when_write_only_entity_queried_for_read_then_access_error_is_returned(self):
+        a_writeonly_reference = self.requireEntityReferenceFixture(
+            "a_reference_to_a_writeonly_entity", skipTestIfMissing=True
+        )
+        expected_error_message = self.requireFixture("expected_error_message")
+        self.__assert_error(
+            a_writeonly_reference,
+            BatchElementError.ErrorCode.kEntityAccessError,
+            expected_error_message,
+            entity_traits_access=EntityTraitsAccess.kRead,
+        )
+
+    def test_when_multiple_references_for_read_then_same_number_of_returned_trait_sets(self):
+        first_ref = self.requireEntityReferenceFixture(
+            "first_entity_reference", skipTestIfMissing=True
+        )
+        first_trait_set = self.requireFixture("first_entity_trait_set")
+
+        second_ref = self.requireEntityReferenceFixture("second_entity_reference")
+        second_trait_set = self.requireFixture("second_entity_trait_set")
+
+        self.__assert_multiple_references(
+            EntityTraitsAccess.kRead,
+            first_ref,
+            first_trait_set,
+            second_ref,
+            second_trait_set,
+        )
+
+    def test_when_multiple_references_for_write_then_same_number_of_returned_trait_sets(self):
+        first_ref = self.requireEntityReferenceFixture(
+            "first_entity_reference", skipTestIfMissing=True
+        )
+        first_trait_set = self.requireFixture("first_entity_trait_set")
+
+        second_ref = self.requireEntityReferenceFixture("second_entity_reference")
+        second_trait_set = self.requireFixture("second_entity_trait_set")
+
+        self.__assert_multiple_references(
+            EntityTraitsAccess.kWrite,
+            first_ref,
+            first_trait_set,
+            second_ref,
+            second_trait_set,
+        )
+
+    def __assert_multiple_references(
+        self,
+        entity_traits_access,
+        first_ref,
+        first_trait_set,
+        second_ref,
+        second_trait_set,
+    ):
+        assert (
+            first_ref != second_ref
+        ), "Fixture error: first/second_entity_reference must be distinct"
+        assert (
+            first_trait_set != second_trait_set
+        ), "Fixture error: first/second_entity_trait_set must be distinct"
+
+        # Some arbitrary order that's unlikely to be replicated by chance.
+        entity_references = [second_ref, first_ref, first_ref, second_ref, first_ref]
+        expected_trait_sets = [
+            second_trait_set,
+            first_trait_set,
+            first_trait_set,
+            second_trait_set,
+            first_trait_set,
+        ]
+
+        context = self.createTestContext()
+        actual_trait_sets = [None] * len(entity_references)
+
+        self._manager.entityTraits(
+            entity_references,
+            entity_traits_access,
+            context,
+            lambda idx, value: operator.setitem(actual_trait_sets, idx, value),
+            lambda idx, error: self.fail("entityTraits should not fail"),
+        )
+
+        self.assertListEqual(actual_trait_sets, expected_trait_sets)
+
+    def __assert_error(
+        self,
+        entity_reference,
+        expected_error_code,
+        expected_error_message,
+        entity_traits_access=EntityTraitsAccess.kRead,
+    ):
+        context = self.createTestContext()
+        result = [None]
+        self._manager.entityTraits(
+            [entity_reference],
+            entity_traits_access,
+            context,
+            lambda idx, value: self.fail("entityTraits should not succeed"),
+            lambda idx, error: operator.setitem(result, idx, error),
+        )
+        [error] = result
+
+        self.assertEqual(error.code, expected_error_code)
+        self.assertEqual(error.message, expected_error_message)
+
+
 class Test_resolve(FixtureAugmentedTestCase):
     """
     Check plugin's implementation of
diff --git a/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py b/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py
index f793d5c3d..c00a5becd 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_manager_gil.py
@@ -56,8 +56,8 @@ def test_all_methods_covered(self, find_unimplemented_test_cases):
             for method in unimplemented:
                 print(
                     f"""
-    def test_{method}(self, a_threaded_manager, a_context, a_host_session):
-        a_threaded_manager.{method}(a_context, a_host_session)
+    def test_{method}(self, a_threaded_manager, a_context):
+        a_threaded_manager.{method}(a_context)
 """
                 )
 
@@ -111,6 +111,13 @@ def test_entityExists(self, a_threaded_manager, a_context):
 
         a_threaded_manager.entityExists([], a_context, fail, fail)
 
+    def test_entityTraits(self, a_threaded_manager, a_context):
+        # Defend against forgetting to include convenience signatures in
+        # this test, once added.
+        assert "Overloaded" not in a_threaded_manager.entityExists.__doc__
+
+        a_threaded_manager.entityTraits([], access.EntityTraitsAccess.kRead, a_context, fail, fail)
+
     def test_flushCaches(self, a_threaded_manager):
         a_threaded_manager.flushCaches()
 
diff --git a/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py b/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py
index 7596a3d98..8c02b1d33 100644
--- a/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py
+++ b/src/openassetio-python/tests/cmodule/gil/test_managerinterface_gil.py
@@ -114,6 +114,11 @@ def test_displayName(self, mock_manager_interface, a_threaded_mock_manager_inter
     def test_entityExists(self, a_threaded_mock_manager_interface, a_context, a_host_session):
         a_threaded_mock_manager_interface.entityExists([], a_context, a_host_session, fail, fail)
 
+    def test_entityTraits(self, a_threaded_mock_manager_interface, a_context, a_host_session):
+        a_threaded_mock_manager_interface.entityTraits(
+            [], access.EntityTraitsAccess.kRead, a_context, a_host_session, fail, fail
+        )
+
     def test_flushCaches(self, a_threaded_mock_manager_interface, a_host_session):
         a_threaded_mock_manager_interface.flushCaches(a_host_session)
 
diff --git a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
index 5df238525..7cda5a567 100644
--- a/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
+++ b/src/openassetio-python/tests/cmodule/resources/_openassetio_test/gilTest.cpp
@@ -104,6 +104,7 @@ struct ThreadedManagerInterface : managerApi::ManagerInterface {
   IMPLEMENT_MOCK2(stateFromPersistenceToken);
   IMPLEMENT_MOCK2(isEntityReferenceString);
   IMPLEMENT_MOCK5(entityExists);
+  IMPLEMENT_MOCK6(entityTraits);
   IMPLEMENT_MOCK7(resolve);
   IMPLEMENT_MOCK6(defaultEntityReference);
   IMPLEMENT_MOCK9(getWithRelationship);
diff --git a/src/openassetio-python/tests/conftest.py b/src/openassetio-python/tests/conftest.py
index b98fe18e5..338a5d8d5 100644
--- a/src/openassetio-python/tests/conftest.py
+++ b/src/openassetio-python/tests/conftest.py
@@ -30,6 +30,7 @@
     RelationsAccess,
     PublishingAccess,
     DefaultEntityAccess,
+    EntityTraitsAccess,
 )
 from openassetio.log import LoggerInterface
 from openassetio.managerApi import (
@@ -258,6 +259,29 @@ def entityExists(self, entityRefs, context, hostSession, successCallback, errorC
             entityRefs, context, hostSession, successCallback, errorCallback
         )
 
+    def entityTraits(
+        self,
+        entityRefs,
+        entityTraitsAccess,
+        context,
+        hostSession,
+        successCallback,
+        errorCallback,
+    ):
+        self.__assertIsIterableOf(entityRefs, EntityReference)
+        assert isinstance(entityTraitsAccess, EntityTraitsAccess)
+        self.__assertCallingContext(context, hostSession)
+        assert callable(successCallback)
+        assert callable(errorCallback)
+        return self.mock.entityTraits(
+            entityRefs,
+            entityTraitsAccess,
+            context,
+            hostSession,
+            successCallback,
+            errorCallback,
+        )
+
     def resolve(
         self,
         entityRefs,
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index b43af7e70..d86d70d16 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -16,6 +16,7 @@
 """
 Tests that cover the openassetio.hostApi.Manager wrapper class.
 """
+import itertools
 
 # pylint: disable=invalid-name,redefined-outer-name,unused-argument
 # pylint: disable=too-many-lines,too-many-locals
@@ -241,6 +242,22 @@ def test_when_entity_ref_prefix_type_invalid_then_debug_log_printed(
         )
 
 
+required_capabilities = [
+    ManagerInterface.Capability.kEntityReferenceIdentification,
+    ManagerInterface.Capability.kManagementPolicyQueries,
+    ManagerInterface.Capability.kEntityTraitIntrospection,
+]
+
+# The powerset (set of all subsets) of capabilities, minus the empty
+# set. Adapted from https://docs.python.org/3/library/itertools.html#itertools-recipes
+required_capabilities_powerset = list(
+    itertools.chain.from_iterable(
+        itertools.combinations(required_capabilities, r)
+        for r in range(1, len(required_capabilities) + 1)
+    )
+)
+
+
 class Test_Manager_initialize_capablility_check:
     def test_has_capability_called_after_initialize(
         self, manager, a_host_session, mock_manager_interface
@@ -249,27 +266,18 @@ def test_has_capability_called_after_initialize(
 
         manager.initialize({})
 
-        assert mock_manager_interface.mock.method_calls[0:3] == [
+        assert mock_manager_interface.mock.method_calls[0:4] == [
             mock.call.initialize({}, a_host_session),
             mock.call.hasCapability(ManagerInterface.Capability.kEntityReferenceIdentification),
             mock.call.hasCapability(ManagerInterface.Capability.kManagementPolicyQueries),
+            mock.call.hasCapability(ManagerInterface.Capability.kEntityTraitIntrospection),
         ]
 
     def test_when_manager_has_capabilities_then_no_error(self, manager, mock_manager_interface):
         mock_manager_interface.mock.hasCapability.return_value = True
         manager.initialize({})
 
-    @pytest.mark.parametrize(
-        "missing_capabilities",
-        [
-            [ManagerInterface.Capability.kEntityReferenceIdentification],
-            [ManagerInterface.Capability.kManagementPolicyQueries],
-            [
-                ManagerInterface.Capability.kEntityReferenceIdentification,
-                ManagerInterface.Capability.kManagementPolicyQueries,
-            ],
-        ],
-    )
+    @pytest.mark.parametrize("missing_capabilities", required_capabilities_powerset)
     def test_when_manager_does_not_have_capability_then_ConfigurationException_raised(
         self, manager, mock_manager_interface, missing_capabilities
     ):
@@ -1366,6 +1374,51 @@ def call_callbacks(*_args):
         assert actual_traitsdata_and_error[3] is traitsdata3
 
 
+class Test_Manager_entityTraits:
+    def test_wraps_the_corresponding_method_of_the_held_interface(
+        self,
+        manager,
+        mock_manager_interface,
+        a_host_session,
+        some_refs,
+        an_entity_trait_set,
+        a_context,
+        a_batch_element_error,
+        invoke_entityTraits_success_cb,
+        invoke_entityTraits_error_cb,
+    ):
+        success_callback = mock.Mock()
+        error_callback = mock.Mock()
+
+        method = mock_manager_interface.mock.entityTraits
+
+        def call_callbacks(*_args):
+            invoke_entityTraits_success_cb(123, an_entity_trait_set)
+            invoke_entityTraits_error_cb(456, a_batch_element_error)
+
+        method.side_effect = call_callbacks
+
+        manager.entityTraits(
+            some_refs,
+            access.EntityTraitsAccess.kRead,
+            a_context,
+            success_callback,
+            error_callback,
+        )
+
+        method.assert_called_once_with(
+            some_refs,
+            access.EntityTraitsAccess.kRead,
+            a_context,
+            a_host_session,
+            mock.ANY,
+            mock.ANY,
+        )
+
+        success_callback.assert_called_once_with(123, an_entity_trait_set)
+        error_callback.assert_called_once_with(456, a_batch_element_error)
+
+
 class Test_Manager_managementPolicy:
     def test_method_defined_in_cpp(self, method_introspector):
         assert not method_introspector.is_defined_in_python(Manager.managementPolicy)
@@ -3049,6 +3102,24 @@ def invoke(idx, batch_element_error):
     return invoke
 
 
+@pytest.fixture
+def invoke_entityTraits_success_cb(mock_manager_interface):
+    def invoke(idx, traits_data):
+        callback = mock_manager_interface.mock.entityTraits.call_args[0][4]
+        callback(idx, traits_data)
+
+    return invoke
+
+
+@pytest.fixture
+def invoke_entityTraits_error_cb(mock_manager_interface):
+    def invoke(idx, batch_element_error):
+        callback = mock_manager_interface.mock.entityTraits.call_args[0][5]
+        callback(idx, batch_element_error)
+
+    return invoke
+
+
 @pytest.fixture
 def invoke_preflight_success_cb(mock_manager_interface):
     def invoke(idx, traits_data):
diff --git a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
index a0c9c043a..54fe75860 100644
--- a/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
+++ b/src/openassetio-python/tests/package/managerApi/test_managerinterface.py
@@ -65,7 +65,7 @@ def test_when_not_overridden_then_returns_empty_dict(self):
 
 class Test_ManagerInterface_Capability:
     def test_has_expected_values(self):
-        assert len(ManagerInterface.Capability.__members__.values()) == 9
+        assert len(ManagerInterface.Capability.__members__.values()) == 10
         assert ManagerInterface.Capability.kEntityReferenceIdentification.value == 0
         assert ManagerInterface.Capability.kManagementPolicyQueries.value == 1
         assert ManagerInterface.Capability.kStatefulContexts.value == 2
@@ -75,6 +75,7 @@ def test_has_expected_values(self):
         assert ManagerInterface.Capability.kRelationshipQueries.value == 6
         assert ManagerInterface.Capability.kExistenceQueries.value == 7
         assert ManagerInterface.Capability.kDefaultEntityReferences.value == 8
+        assert ManagerInterface.Capability.kEntityTraitIntrospection.value == 9
 
 
 class Test_ManagerInterface_kCapabilityNames:
@@ -123,6 +124,12 @@ def test_names_indices_match_constants(self):
             ]
             == "defaultEntityReferences"
         )
+        assert (
+            ManagerInterface.kCapabilityNames[
+                int(ManagerInterface.Capability.kEntityTraitIntrospection)
+            ]
+            == "entityTraitIntrospection"
+        )
 
 
 class Test_ManagerInterface_hasCapability:
@@ -332,6 +339,24 @@ def fail(*_):
             manager_interface.entityExists([], a_context, a_host_session, fail, fail)
 
 
+class Test_ManagerInterface_entityTraits:
+    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(
+                "entityTraits", "entityTraitIntrospection"
+            ),
+        ):
+            manager_interface.entityTraits(
+                [], access.EntityTraitsAccess.kRead, a_context, a_host_session, fail, fail
+            )
+
+
 class Test_ManagerInterface_resolve:
     def test_default_implementation_raises_NotImplementedException(
         self, manager_interface, a_context, a_host_session, unimplemented_method_error_msg
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 54e9054cf..1da1653d5 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
@@ -62,6 +62,7 @@ def hasCapability(self, capability):
         return capability in (
             ManagerInterface.Capability.kEntityReferenceIdentification,
             ManagerInterface.Capability.kManagementPolicyQueries,
+            ManagerInterface.Capability.kEntityTraitIntrospection,
         )
 
     def managementPolicy(self, traitSets, access, context, hostSession):
diff --git a/src/openassetio-python/tests/package/test_access.py b/src/openassetio-python/tests/package/test_access.py
index c61ab7f6e..ce6cc91cc 100644
--- a/src/openassetio-python/tests/package/test_access.py
+++ b/src/openassetio-python/tests/package/test_access.py
@@ -23,6 +23,7 @@
 import pytest
 
 from openassetio.access import (
+    EntityTraitsAccess,
     PolicyAccess,
     RelationsAccess,
     ResolveAccess,
@@ -89,6 +90,11 @@ def test_has_expected_constants(self, root_enum):
         assert_expected_enum_values(ResolveAccess, root_enum.kRead, root_enum.kWrite)
 
 
+class Test_EntityTraitsAccess:
+    def test_has_expected_constants(self, root_enum):
+        assert_expected_enum_values(EntityTraitsAccess, root_enum.kRead, root_enum.kWrite)
+
+
 class Test_PublishingAccess:
     def test_has_expected_constants(self, root_enum):
         assert_expected_enum_values(PublishingAccess, root_enum.kWrite, root_enum.kCreateRelated)