Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
C++ Manager Plugin for Testing
## What

We want to build a simple C++ manager plugin to be used for testing the C++ manager system.

## Acceptance Criteria

* A manager plugin inside the OpenAssetIO examples directory. 
* It should only do the required capabilities and resolve.
* Entity references it resolves are hard coded, we have BAL as a mock.
* Independently buildable, not linked to OpenAssetIO's build system, but there should be CI/CTest tests to make sure it builds and runs.
* Link from README "quick start" section.

## Out of Scope

Not packaged and published, to build it, you'll need to pull the OpenAssetIO repository.
C++ Manager Plugin for Testing
## What

We want to build a simple C++ manager plugin to be used for testing the C++ manager system.

## Acceptance Criteria

* A manager plugin inside the OpenAssetIO examples directory. 
* It should only do the required capabilities and resolve.
* Entity references it resolves are hard coded, we have BAL as a mock.
* Independently buildable, not linked to OpenAssetIO's build system, but there should be CI/CTest tests to make sure it builds and runs.
* Link from README "quick start" section.

## Out of Scope

Not packaged and published, to build it, you'll need to pull the OpenAssetIO repository.
Fix loading C++ plugins from Python
## What

Allow C++ plugins to be loaded from a Python environment for both CMake shared library installs as well as PyPI pip installs.

## Why

Currently, C++ plugins cannot be loaded by our PyPI package, because the Python extension module `openassetio._openassetio` statically links in the core C++ library, and hides all its symbols, so the plugin cannot link when loaded at runtime.

There are three issues at play here
- The `_openassetio` library hides all symbols from the (static) core C++ library, so the plugin cannot link when loaded.
- Python (on POSIX) loads with `RTLD_LOCAL`, meaning the symbols are unavailable to subsequently loaded libraries, even if they are public.
- Ideally the plugin would have no dependency on the core OpenAssetIO C++ library, and would instead rely on symbols being already available in the process environment when it is loaded. 
    - However, even if the `_openassetio` library and core C++ library are split into two shared libraries, `RTLD_LOCAL` is transitive, so would apply to the core C++ library too. So the plugin cannot rely on the symbols being present in the environment.
    - Windows is another question, and probably enforces `RTLD_LOCAL`-like behaviour regardless (needs more research).
    - A workaround, therefore, and possibly necessary (depending on Windows), is that the plugin must explicitly depend on the OpenAssetIO core C++ library.
 
Original investigation: https://github.com/OpenAssetIO/OpenAssetIO/issues/1324#issuecomment-2197460862

<!---
## Acceptance Criteria

An exhaustive list of falsifiable outcomes that define fully whether
this task is complete.

Remember to consider:

- Release note updates.
- Documentation updates.
- Additional tests or test updates.
- Impact on dependent repos.
--->



Discussion / hints:
Whilst developing this we discovered that the PyPI build of OpenAssetIO _cannot_ load C++ manager plugins.

This is because the PyPI build uses a single shared library (the Python extension module) with the core C++ library baked into it as a static library.

This causes two problems:
* Our build system setup is designed to block all symbols from being exported except for those explicitly made visible. This means the Python extension module hides the symbols from the "foreign" C++ core library, so there are no symbols for a C++ manager plugin to link to.
* Python extension modules are loaded by the Python interpreter using `RTLD_LOCAL` (on POSIX), which prevents symbols from "leaking" between binaries, so even if our Python extension module exposes the necessary symbols, they will be "blocked" from other shared libraries.

The first problem could be solved by refactoring our build system to allow the `libopenassetio.a` library's symbols to be exported from the `_openassetio.so` Python extension module. This means 
* No longer having CMake generate an `export.h` header (`generate_export_header()`), since the generated export header for a static library is just a stub without any visibility flags. Instead, we would have to roll our own that works for both static and shared libraries and across platforms (lots of other projects do this).
* Ensure we use `$<LINK_LIBRARY:WHOLE_ARCHIVE,openassetio-core>`, so that _all_ symbols, not just those used by the Python extension module, are exported from the static library.
* Somehow exclude the core C++ library from the `--exclude-libs,ALL` linker argument, probably requiring us to eschew `ALL` and instead provide an explicit list of what libraries to exclude.

The second problem is more serious, though easier to "fix".
* We would have to (temporarily) override the flags using `sys.setdlopenflags(os.RTLD_GLOBAL | os.RTLD_LAZY)`. Though of course that's POSIX only.
* However, the [Python docs](https://docs.python.org/3/extending/extending.html) say 
  > Portability therefore requires not to make any assumptions about symbol visibility. This means that all symbols in extension modules should be declared static, except for the module’s initialization function, in order to avoid name clashes with other extension modules [...] And it means that symbols that should be accessible from other extension modules must be exported in a different way.

That last point especially means that the _correct_ solution would be to split the Python extension module and the core C++ (shared) library, so that the extension module can depend on the shared library, as can plugins.

Note that all the above applies to POSIX. Windows is another story and would need alternative fixes, if any are even possible (not explored in my investigations).

In the past, separating the core C++ library and the Python extension module in the PyPI package caused problems with linker search paths.  On Linux we have `RPATH` and on macOS we have `@loader_path`, but on Windows we have nothing. Fixing this will take further experimentation/consultation.
Whilst developing this we discovered that the PyPI build of OpenAssetIO _cannot_ load C++ manager plugins.

This is because the PyPI build uses a single shared library (the Python extension module) with the core C++ library baked into it as a static library.

This causes two problems:
* Our build system setup is designed to block all symbols from being exported except for those explicitly made visible. This means the Python extension module hides the symbols from the "foreign" C++ core library, so there are no symbols for a C++ manager plugin to link to.
* Python extension modules are loaded by the Python interpreter using `RTLD_LOCAL` (on POSIX), which prevents symbols from "leaking" between binaries, so even if our Python extension module exposes the necessary symbols, they will be "blocked" from other shared libraries.

The first problem could be solved by refactoring our build system to allow the `libopenassetio.a` library's symbols to be exported from the `_openassetio.so` Python extension module. This means 
* No longer having CMake generate an `export.h` header (`generate_export_header()`), since the generated export header for a static library is just a stub without any visibility flags. Instead, we would have to roll our own that works for both static and shared libraries and across platforms (lots of other projects do this).
* Ensure we use `$<LINK_LIBRARY:WHOLE_ARCHIVE,openassetio-core>`, so that _all_ symbols, not just those used by the Python extension module, are exported from the static library.
* Somehow exclude the core C++ library from the `--exclude-libs,ALL` linker argument, probably requiring us to eschew `ALL` and instead provide an explicit list of what libraries to exclude.

The second problem is more serious, though easier to "fix".
* We would have to (temporarily) override the flags using `sys.setdlopenflags(os.RTLD_GLOBAL | os.RTLD_LAZY)`. Though of course that's POSIX only.
* However, the [Python docs](https://docs.python.org/3/extending/extending.html) say 
  > Portability therefore requires not to make any assumptions about symbol visibility. This means that all symbols in extension modules should be declared static, except for the module’s initialization function, in order to avoid name clashes with other extension modules [...] And it means that symbols that should be accessible from other extension modules must be exported in a different way.

That last point especially means that the _correct_ solution would be to split the Python extension module and the core C++ (shared) library, so that the extension module can depend on the shared library, as can plugins.

Note that all the above applies to POSIX. Windows is another story and would need alternative fixes, if any are even possible (not explored in my investigations).

In the past, separating the core C++ library and the Python extension module in the PyPI package caused problems with linker search paths.  On Linux we have `RPATH` and on macOS we have `@loader_path`, but on Windows we have nothing. Fixing this will take further experimentation/consultation.
Add a CI job that pip installs OpenAssetIO and uses simpleResolver to resolve from SimpleCppManager. This will test linkage etc.
Note that building as multiple shared libraries is insufficient to provide the ideal plugin loading environment.

For example when split into multiple shared libraries, Python will transitively load `libopenassetio` but _still_ not expose its symbols (`RTLD_LOCAL` is transitive).  So C++ plugins still won't load _unless_ the plugin has its own dependency on `libopenassetio` (which is at least possible when using a shared library build).  I.e. the plugin cannot trust that the necessary symbols will be available in the process environment.
Updated description to focus specifically on the Python package, since that's the end goal. We should still consider warning users that static library builds are unsupported.

Fix patch:
diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml
index e82be15e9..23e52a6fa 100644
--- a/.github/workflows/integrations.yml
+++ b/.github/workflows/integrations.yml
@@ -193,3 +193,36 @@ jobs:
         env:
           CMAKE_PREFIX_PATH: ${{ github.workspace }}/dist
           CMAKE_TOOLCHAIN_FILE: ${{ github.workspace }}/.conan/conan_paths.cmake
+
+      # TODO(DF): SimpleCppManager should (also) be tested as part of
+      # Python e2e tests, once we fix loading C++ plugins from Python.
+
+      - name: Build/install SimpleCppManager
+        run: >
+          ${{ matrix.config.preamble }}
+
+          cd examples/manager/SimpleCppManager
+
+          cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
+          -DOPENASSETIO_ENABLE_TESTS=ON
+          -DOPENASSETIO_GLIBCXX_USE_CXX11_ABI=OFF
+
+          cmake --build build --parallel --config RelWithDebInfo
+
+          cmake --install build --prefix '${{ github.workspace }}/dist/plugins'
+          --config RelWithDebInfo
+
+        env:
+          CMAKE_PREFIX_PATH: ${{ github.workspace }}/dist
+
+      - name: Test SimpleCppManager with simpleResolver
+        run: >
+          python '${{ github.workspace }}/examples/host/simpleResolver/simpleResolver.py'
+          openassetio-mediacreation:identity.DisplayName simplecpp://test/entity/1
+          | grep 'Test Entity 1'
+
+        env:
+          PYTHONPATH: ${{ github.workspace }}/dist/${{ matrix.config.site-packages }}
+          OPENASSETIO_LOGGING_SEVERITY: 1
+          OPENASSETIO_PLUGIN_PATH: ${{ github.workspace }}/dist/plugins
+          OPENASSETIO_DEFAULT_CONFIG: ${{ github.workspace }}/examples/manager/SimpleCppManager/tests/resources/openassetio_config.toml
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e1b284113..317fd7000 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -205,6 +205,8 @@ cmake_dependent_option(
     OFF
 )
 
+option(OPENASSETIO_ENABLE_SIMPLECPPMANAGER "Build the SimpleCppManager example" OFF)
+
 # Enable clang-format formatting check.
 option(OPENASSETIO_ENABLE_CLANG_FORMAT "Enable clang-format check during build" OFF)
 
@@ -339,11 +341,12 @@ if (OPENASSETIO_ENABLE_PYTHON)
     message(STATUS "Python relative install dir        = ${OPENASSETIO_PYTHON_SITEDIR}")
     message(STATUS "Generate .pyi stubs                = ${OPENASSETIO_ENABLE_PYTHON_STUBGEN}")
 endif ()
+message(STATUS "Build SimpleCppManager             = ${OPENASSETIO_ENABLE_SIMPLECPPMANAGER}")
 message(STATUS "Create test targets                = ${OPENASSETIO_ENABLE_TESTS}")
 if (OPENASSETIO_ENABLE_TESTS)
     message(STATUS "Create Python venv during tests    = ${OPENASSETIO_ENABLE_PYTHON_TEST_VENV}")
     message(STATUS "Enable ABI diff check test         = ${OPENASSETIO_ENABLE_TEST_ABI}")
-endif()
+endif ()
 message(STATUS "Warnings as errors                 = ${OPENASSETIO_WARNINGS_AS_ERRORS}")
 message(STATUS "Interprocedural optimization       = ${OPENASSETIO_ENABLE_IPO}")
 message(STATUS "Enable PIC for static libs         = ${OPENASSETIO_ENABLE_POSITION_INDEPENDENT_CODE}")
@@ -367,3 +370,7 @@ message(STATUS "Linter: cmake-lint                 = ${OPENASSETIO_ENABLE_CMAKE_
 # Recurse to library targets
 
 add_subdirectory(src)
+
+if (OPENASSETIO_ENABLE_SIMPLECPPMANAGER)
+    add_subdirectory(examples/manager/SimpleCppManager)
+endif ()
diff --git a/CMakePresets.json b/CMakePresets.json
index 669a20437..05a45904a 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -33,7 +33,8 @@
         "enable-tests"
       ],
       "cacheVariables": {
-        "OPENASSETIO_ENABLE_PYTHON_STUBGEN": "OFF"
+        "OPENASSETIO_ENABLE_PYTHON_STUBGEN": "OFF",
+        "OPENASSETIO_ENABLE_SIMPLECPPMANAGER": "OFF"
       }
     },
     {
@@ -104,7 +105,8 @@
       "hidden": true,
       "cacheVariables": {
         "OPENASSETIO_ENABLE_PYTHON": "ON",
-        "OPENASSETIO_ENABLE_C": "ON"
+        "OPENASSETIO_ENABLE_C": "ON",
+        "OPENASSETIO_ENABLE_SIMPLECPPMANAGER": "ON"
       }
     },
     {
diff --git a/README.md b/README.md
index a08fc7dd0..ef9a93978 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,12 @@ workflows in content creation tooling.
 
 > - 👋 Come chat with us on [ASWF Slack](https://academysoftwarefdn.slack.com/archives/C03Q36QS8N4)
 >   or our [mailing list](https://lists.aswf.io/g/openassetio-discussion).
-> - 🧰 Get started with the API [docs](https://openassetio.github.io/OpenAssetIO),
+> - 🧰 Get started with the [API docs](https://openassetio.github.io/OpenAssetIO),
 >   Manager plugin [template](https://github.com/OpenAssetIO/Template-OpenAssetIO-Manager-Python),
->   [OpenAssetIO-MediaCreation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation#readme)
+>   and [MediaCreation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation#readme)
 >   for post-production workflows.
-> - 📖 Or see some examples: Ayon [manager plugin](https://github.com/ynput/ayon-openassetio-manager-plugin/tree/main#readme),
->   USD [Asset Resolver prototype](https://github.com/OpenAssetIO/usdOpenAssetIOResolver#readme),
->   OTIO [media linker](https://github.com/OpenAssetIO/otio-openassetio)
+> - 📖 Or see some [examples](examples/README.md) in the OpenAssetIO
+>   ecosystem.
 > - 👀 You can follow the development effort [here](https://github.com/orgs/OpenAssetIO/projects/1/views/7)
 >   and the longer term roadmap [here](ROADMAP.md).
 
@@ -239,4 +238,4 @@ During this period, one may assume that the `x` above is synonymous with
 major version, and the `y` with minor version, for the purpose of
 compatibility. This means patch versions will be rolled into minor
 versions, but you may still consult the [release
-notes](./RELEASE_NOTES.md) for specific compatibility information.
\ No newline at end of file
+notes](./RELEASE_NOTES.md) for specific compatibility information.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index dd710464c..a8d0dbe75 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -4,11 +4,21 @@ Release Notes
 v1.0.0-beta.x.x
 ---------------
 
+### New Features
+
+- Added SimpleCppManager - a minimal C++ manager and plugin example
+  implementation, useful for testing C++ plugin support in hosts. This
+  has minimal API support, so [Basic Asset Library (BAL)](https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL)
+  should still be preferred as a test manager when Python is available.
+  [#1324](https://github.com/OpenAssetIO/OpenAssetIO/issues/1324)
+
 ### Improvements
 
 - Added singular overload of `managementPolicy` for convenience.
   [#856](https://github.com/OpenAssetIO/OpenAssetIO/issues/856)
 
+- Updated the `simpleResolver` example host to support C++ plugins.
+  [#1324](https://github.com/OpenAssetIO/OpenAssetIO/issues/1324)
 
 v1.0.0-beta.2.2
 ---------------
diff --git a/cmake/ThirdParty.cmake b/cmake/ThirdParty.cmake
index ec1481e2e..81ebea999 100644
--- a/cmake/ThirdParty.cmake
+++ b/cmake/ThirdParty.cmake
@@ -68,10 +68,12 @@ if (OPENASSETIO_ENABLE_PYTHON)
         # varying installation structures were used (eg GitHub Actions
         # runners).
         if (WIN32) # Should not use 'bin' for Windows
-            set(OPENASSETIO_PYTHON_SITEDIR "Lib/site-packages")
+            set(OPENASSETIO_PYTHON_SITEDIR "Lib/site-packages"
+                CACHE STRING "Python site-packages directory" FORCE)
         else ()
             set(OPENASSETIO_PYTHON_SITEDIR
-                "lib/python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}/site-packages")
+                "lib/python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}/site-packages"
+                CACHE STRING "Python site-packages directory" FORCE)
         endif ()
     endif ()
 
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 000000000..23bfb2e26
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,100 @@
+# Examples
+
+There is an ever-growing list of example OpenAssetIO hosts and manager
+plugins. They cover a large range, including reference implementations,
+testing utilities, proof of concepts, and production integrations.
+
+## This repository
+
+In this repository you can find simple examples of host applications
+and manager plugins that make use of OpenAssetIO.
+
+### Hosts
+
+#### simpleResolver
+
+The [simpleResolver](host/simpleResolver/README.md) is a Python
+command-line tool that can take an entity reference and a list of
+trait IDs, `resolve` them, and present the results as a dictionary
+in the terminal.
+
+### Managers
+
+#### SimpleCppManager
+
+The [SimpleCppManager](manager/SimpleCppManager/README.md) is a C++
+OpenAssetIO manager plugin that provides functionality for a limited
+cross-section of the OpenAssetIO API. Its "database" of entities is
+taken directly from the settings provided by the host. It is designed to
+be used for testing C++ plugin support in host applications.
+
+## Other OpenAssetIO projects
+
+### Python manager plugin template
+
+The [Python manager plugin template](https://github.com/OpenAssetIO/Template-OpenAssetIO-Manager-Python)
+provides a documentation-by-example implementation of a Python manager
+plugin.
+
+### Jupyter notebooks
+
+An ever-expanding collection of [Jupyter](https://jupyter.org/)
+notebooks is available as
+[examples in the MediaCreation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation/tree/main/examples)
+repository. These demonstrate core OpenAssetIO concepts alongside the
+wider ecosystem, in particular as it relates to the VFX and media
+creation industry, through executable Python snippets. The pre-rendered
+notebooks can be viewed directly in GitHub, so downloading to execute
+locally is not required.
+
+### Basic Asset Library
+
+The [Basic Asset Library
+(BAL)](https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL) is a
+Python OpenAssetIO manager and plugin that is designed to enable host
+applications to test their integration against a wide spectrum of
+possible asset management system archetypes. It is designed to be fully
+puppetable, with responses controlled by a JSON configuration file. BAL
+is used extensively by the team for internal testing and demos of
+OpenAssetIO. Note that it is not intended as a reference implementation.
+
+### USD Ar2 shim plugin
+
+The [USD Asset Resolver plugin](https://github.com/OpenAssetIO/usdOpenAssetIOResolver#readme)
+allows OpenAssetIO plugins to be used within the USD ecosystem, by
+adapting the Ar2 API to the OpenAssetIO API.
+
+### OpenTimelineIO media linker plugin
+
+The [OTIO media linker](https://github.com/OpenAssetIO/otio-openassetio)
+plugin allows OpenAssetIO entity references to be recognised within
+OTIO documents, and resolved to their file path when the document is
+loaded.
+
+## External projects
+
+### Nuke
+
+[Nuke](https://www.foundry.com/products/nuke-family/nuke) is the
+industry-leading commercial compositing tool by Foundry. As of
+version [15.1](https://campaigns.foundry.com/products/nuke-family/whats-new),
+Nuke has built-in support for OpenAssetIO as a headline feature.
+
+### Ayon manager plugin
+
+[Ayon by Ynput](https://ynput.io/ayon/) is an open source asset
+management system that forms part of Ynput's pipeline-as-service
+offering. Their
+[manager plugin](https://github.com/ynput/ayon-openassetio-manager-plugin)
+is available on GitHub.
+
+### Blender OpenAssetIO demo script
+
+[Blender](https://www.blender.org/) is an open source 3D digital content
+creation tool.
+
+The [Blender relationships demo script](https://github.com/elliotcmorris/openassetio-relationships-blender)
+adds UI elements to Blender that allow a user to load external geometry
+from an asset management system, and pick alternative proxy
+representations of that geometry by querying OpenAssetIO entity
+relationships.
diff --git a/examples/host/simpleResolver/simpleResolver.py b/examples/host/simpleResolver/simpleResolver.py
index c3babf88d..909ebb0fb 100755
--- a/examples/host/simpleResolver/simpleResolver.py
+++ b/examples/host/simpleResolver/simpleResolver.py
@@ -28,9 +28,12 @@
 from openassetio.errors import BatchElementException
 from openassetio.access import ResolveAccess
 from openassetio.hostApi import HostInterface, ManagerFactory
-from openassetio.errors import ConfigurationException
+from openassetio.errors import ConfigurationException, OpenAssetIOException
 from openassetio.log import ConsoleLogger, SeverityFilter
-from openassetio.pluginSystem import PythonPluginSystemManagerImplementationFactory
+from openassetio.pluginSystem import (
+    PythonPluginSystemManagerImplementationFactory,
+    CppPluginSystemManagerImplementationFactory,
+)
 
 
 # pylint: disable=missing-function-docstring,no-self-use,invalid-name
@@ -147,20 +150,31 @@ def main():
     # used in standalone situations such as this.
     logger = SeverityFilter(ConsoleLogger())
 
-    # The Python plugin system can load manager implementations
-    # defined outside of the API package.
-    impl_factory = PythonPluginSystemManagerImplementationFactory(logger)
-
     # The HostInterface implementation we supply that identifies
     # ourselves to the manager
     host_interface = SimpleResolverHostInterface()
 
+    # The C++ plugin system can load manager implementations
+    # defined outside of the API package.
+    impl_factory = CppPluginSystemManagerImplementationFactory(logger)
+
     # Initialize the default manager as configured by $OPENASSETIO_DEFAULT_CONFIG
     # See: https://openassetio.github.io/OpenAssetIO/classopenassetio_1_1v1_1_1host_api_1_1_manager_factory.html#a8b6c44543faebcb1b441bbf63c064c76
     # All API/Manager messaging is channeled through the supplied logger.
-    manager = ManagerFactory.defaultManagerForInterface(host_interface, impl_factory, logger)
+    try:
+        manager = ManagerFactory.defaultManagerForInterface(host_interface, impl_factory, logger)
+    except OpenAssetIOException:
+        # Exceptions occur when configuration exists, but loading the
+        # config file or the plugin fails. E.g. if the config file
+        # specifies the identifier of a Python plugin, but we tried to
+        # locate and load a C++ plugin.
+        # If a C++ plugin was not found, try the Python plugin system.
+        impl_factory = PythonPluginSystemManagerImplementationFactory(logger)
+        manager = ManagerFactory.defaultManagerForInterface(host_interface, impl_factory, logger)
 
     if not manager:
+        # Manager will be None only if OPENASSETIO_DEFAULT_CONFIG is
+        # not set.
         raise ConfigurationException(
             "No default manager configured, "
             f"check ${ManagerFactory.kDefaultManagerConfigEnvVarName}"
diff --git a/examples/manager/SimpleCppManager/CMakeLists.txt b/examples/manager/SimpleCppManager/CMakeLists.txt
new file mode 100644
index 000000000..a1775f60d
--- /dev/null
+++ b/examples/manager/SimpleCppManager/CMakeLists.txt
@@ -0,0 +1,144 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2024 The Foundry Visionmongers Ltd
+cmake_minimum_required(VERSION 3.27)
+# A stub C++ manager plugin useful for testing against.
+
+#-----------------------------------------------------------------------
+# Initialise project.
+
+project(SimpleCppManager LANGUAGES CXX)
+
+set(_target_name openassetio.example.${PROJECT_NAME})
+
+add_library(${_target_name} MODULE)
+
+# By default install to the root of the install prefix, so that the
+# destination can be controlled precisely by the user.
+set(OPENASSETIO_SIMPLECPPMANAGER_INSTALL_SUBDIR ".")
+if (DEFINED OPENASSETIO_TEST_CPP_PLUGINS_SUBDIR)
+    # This variable is set either manually or because we're being
+    # configured via the parent OpenAssetIO project.
+    set(OPENASSETIO_SIMPLECPPMANAGER_INSTALL_SUBDIR ${OPENASSETIO_TEST_CPP_PLUGINS_SUBDIR})
+endif ()
+
+install(
+    TARGETS ${_target_name}
+    EXPORT ${PROJECT_NAME}_EXPORTED_TARGETS
+    DESTINATION ${OPENASSETIO_SIMPLECPPMANAGER_INSTALL_SUBDIR}
+)
+
+#-----------------------------------------------------------------------
+# Target properties.
+
+set_target_properties(
+    ${_target_name}
+    PROPERTIES
+
+    # Ensure consistent C++17 standard.
+    CXX_STANDARD 17
+    CXX_STANDARD_REQUIRED YES
+    CXX_EXTENSIONS NO
+
+    # Ensure non-exported symbols are hidden from the host application.
+    C_VISIBILITY_PRESET hidden
+    CXX_VISIBILITY_PRESET hidden
+    VISIBILITY_INLINES_HIDDEN YES
+
+    # Use a predictable name for the plugin binary.
+    OUTPUT_NAME ${PROJECT_NAME}
+    PREFIX ""
+    SOVERSION ""
+    VERSION ""
+)
+
+#-----------------------------------------------------------------------
+# Compiler warnings.
+
+if (MSVC)
+    set(_project_warnings)
+    message(AUTHOR_WARNING
+        "Compiler warnings need configuring for '${CMAKE_CXX_COMPILER_ID}' compiler.")
+
+    if (OPENASSETIO_WARNINGS_AS_ERRORS)
+        set(_project_warnings ${project_warnings} /WX)
+    endif ()
+
+elseif (CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
+    set(_project_warnings -Wall -Wextra -Wpedantic)
+
+    if (OPENASSETIO_WARNINGS_AS_ERRORS)
+        set(_project_warnings ${project_warnings} -Werror)
+    endif ()
+
+else ()
+    message(AUTHOR_WARNING "No compiler warnings set for '${CMAKE_CXX_COMPILER_ID}' compiler.")
+endif ()
+
+target_compile_options(${_target_name} PRIVATE ${_project_warnings})
+
+
+#-----------------------------------------------------------------------
+# GCC ABI.
+
+if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 5.0)
+    if (DEFINED OPENASSETIO_GLIBCXX_USE_CXX11_ABI)
+        if (OPENASSETIO_GLIBCXX_USE_CXX11_ABI)
+            target_compile_definitions(${_target_name} PRIVATE _GLIBCXX_USE_CXX11_ABI=1)
+        else ()
+            target_compile_definitions(${_target_name} PRIVATE _GLIBCXX_USE_CXX11_ABI=0)
+        endif ()
+    endif ()
+endif ()
+
+
+#-----------------------------------------------------------------------
+# API export header.
+
+include(GenerateExportHeader)
+generate_export_header(
+    ${_target_name}
+    EXPORT_FILE_NAME ${CMAKE_CURRENT_BINARY_DIR}/include/export.h
+)
+
+
+#-----------------------------------------------------------------------
+# Target dependencies.
+
+target_sources(${_target_name} PRIVATE src/${PROJECT_NAME}.cpp)
+
+# For generated API export header.
+target_include_directories(${_target_name} PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/include)
+
+if (NOT TARGET OpenAssetIO::openassetio-core)
+    # We're not building as part of OpenAssetIO, so we must find it.
+    find_package(OpenAssetIO REQUIRED)
+endif ()
+
+# Link to the OpenAssetIO core library.
+# TODO(DF): ideally we'd use CMake's `$<COMPILE_ONLY:` here, to avoid
+# baking in a dependency on the OpenAssetIO core library. In theory, the
+# OpenAssetIO symbols should already be available in the process
+# environment by the time the plugin is loaded (its loaded by
+# OpenAssetIO!). However, Python (and any other process that loads
+# OpenAssetIO with RTLD_LOCAL or equivalent) will not expose the
+# OpenAssetIO symbols to the plugin. So we need to link the plugin to
+# the OpenAssetIO library explicitly. This means the plugin will not
+# load if the OpenAssetIO core C++ shared library is not found, e.g. if
+# OpenAssetIO is statically linked into a larger third-party binary.
+target_link_libraries(${_target_name} PRIVATE OpenAssetIO::openassetio-core)
+
+
+#-----------------------------------------------------------------------
+# Tests
+
+if (OPENASSETIO_ENABLE_TESTS)
+    if (NOT TARGET OpenAssetIO::openassetio-python-module)
+        message(FATAL_ERROR
+            "Testing as part of OpenAssetIO requires OPENASSETIO_ENABLE_PYTHON"
+            " for pytest tests to run")
+    endif ()
+
+    enable_testing()
+    add_subdirectory(tests)
+endif ()
+
diff --git a/examples/manager/SimpleCppManager/README.md b/examples/manager/SimpleCppManager/README.md
new file mode 100644
index 000000000..a03dfa24c
--- /dev/null
+++ b/examples/manager/SimpleCppManager/README.md
@@ -0,0 +1,65 @@
+# SimpleCppManager
+
+A simple C++ manager and plugin useful in basic host application test
+cases, where a C++ plugin is required.
+
+Where Python is available, it is recommended to use the [Basic Asset
+Library](https://github.com/OpenAssetIO/OpenAssetIO-Manager-BAL) fake
+manager for testing, which is much more configurable and supports a
+wider cross-section of the OpenAssetIO API.
+
+The SimpleCppManager manager/plugin only implements the minimal required
+methods plus `resolve`. It is entirely configured by the settings
+provided at `initialize` time (typically coming from the standard
+OpenAssetIO `.toml` configuration file). This includes the "database" of
+entities, encoded as a CSV document in a string setting. Note: this is
+not representative of a real-world implementation; it is purely a
+convenient approach for the purposes of configuring test cases - a
+real-world manager plugin would typically retrieve information from a
+dynamic external data source.
+
+## Building
+
+### As part of OpenAssetIO
+
+The plugin can be built and installed as part of OpenAssetIO by setting
+the CMake option `OPENASSETIO_ENABLE_SIMPLECPPMANAGER=ON` when building
+OpenAssetIO.
+
+### Independently
+
+Alternatively, the plugin can be built as an independent CMake project.
+
+Available CMake settings
+
+- `OPENASSETIO_GLIBCXX_USE_CXX11_ABI` - If truthy, and using
+  `libstdc++` (default on Linux), the C++11 ABI will be used rather than
+  the deprecated pre-C++11 ABI. The value of this option must match the
+  identically named option used when building OpenAssetIO. See
+  [VFX Reference Platform](https://vfxplatform.com/).
+- `OPENASSETIO_ENABLE_TESTS` - If truthy, tests will be enabled. These
+  require an OpenAssetIO build with Python enabled, and a Python
+  environment with `pytest` installed.
+
+Assuming the working directory is at the root of the SimpleCppManager
+project, and assuming a POSIX host
+
+```sh
+export CMAKE_PREFIX_PATH=/path/to/OpenAssetIO/dist
+cmake -S . -B build -DOPENASSETIO_GLIBCXX_USE_CXX11_ABI=OFF
+cmake --build build --parallel
+cmake --install build --prefix /path/to/OpenAssetIO/plugins
+```
+
+where `/path/to/OpenAssetIO/plugins` is an arbitrary path to be provided
+in the `OPENASSETIO_PLUGIN_PATH` environment variable.
+
+## Usage
+
+See [example config file](tests/resources/openassetio_config.toml) for
+available configuration options, including constructing a database of
+`resolve`able entities.
+
+Once the plugin has been installed, the usual configuration via
+`OPENASSETIO_PLUGIN_PATH` and `OPENASSETIO_DEFAULT_CONFIG` can be used
+(see API documentation).
diff --git a/examples/manager/SimpleCppManager/src/SimpleCppManager.cpp b/examples/manager/SimpleCppManager/src/SimpleCppManager.cpp
new file mode 100644
index 000000000..5c7e69ec3
--- /dev/null
+++ b/examples/manager/SimpleCppManager/src/SimpleCppManager.cpp
@@ -0,0 +1,643 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2024 The Foundry Visionmongers Ltd
+#include <algorithm>
+#include <optional>
+#include <sstream>
+#include <string>
+#include <string_view>
+
+#include <export.h>
+
+#include <openassetio/errors/exceptions.hpp>
+#include <openassetio/managerApi/EntityReferencePagerInterface.hpp>
+#include <openassetio/managerApi/ManagerInterface.hpp>
+#include <openassetio/pluginSystem/CppPluginSystemManagerPlugin.hpp>
+#include <openassetio/trait/TraitsData.hpp>
+
+// Unique ID of the plugin.
+constexpr std::string_view kPluginId = "org.openassetio.examples.manager.simplecppmanager";
+// Settings keys.
+constexpr std::string_view kSettingsKeyForEntityRefPrefix = "prefix";
+constexpr std::string_view kSettingsKeyForCapabilities = "capabilities";
+constexpr std::string_view kSettingsKeyForReadPolicy = "read_policy";
+constexpr std::string_view kSettingsKeyForReadEntityTraitProperties = "read_traits";
+
+/**
+ * Simple manager implementation.
+ *
+ * This simple manager regurgitates values that are encoded in the
+ * settings dictionary. In particular, the settings can contain a
+ * list of entity references and their associated traits and properties,
+ * encoded as a CSV document.
+ *
+ * Only the required set of capabilities plus "resolution" are
+ * implemented and advertised by default. Any capability can be enabled,
+ * however, to aid in downstream testing. Unsupported methods will then
+ * return a stub response, rather than throw a
+ * `NotImplementedException`.
+ *
+ * @see initialize
+ */
+struct SimpleCppManagerInterface final : openassetio::managerApi::ManagerInterface {
+  [[nodiscard]] openassetio::Identifier identifier() const override {
+    return openassetio::Identifier{kPluginId};
+  }
+
+  /**
+   * Override the initialize method to parse settings for data to
+   * regurgitate.
+   *
+   * The "database" of entities is specified as a CSV document in the
+   * settings dict.
+   *
+   * Similarly other settings are available to make this manager more
+   * puppetable. These include:
+   * - "prefix" - Prefix for entity references.
+   * - "capabilities" - CSV list of capabilities.
+   * - "read_policy" - Trait for successful managementPolicy queries.
+   * - "read_traits" - CSV document of entity trait properties.
+   *
+   * Typically these settings are provided by the toml config file (see
+   * OPENASSETIO_DEFAULT_CONFIG), but they can also be provided by the
+   * host application (including as part of a re-`initialize`), or as
+   * fixtures in the openassetio.test.manager API compliance test suite.
+   */
+  void initialize(
+      openassetio::InfoDictionary managerSettings,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    // Settings can be sparse (so that hosts can update just a subset of
+    // settings), so merge in previous settings. Note that `merge` does
+    // not overwrite existing keys.
+    managerSettings.merge(settings_);
+
+    // Utility function to extract a setting from the settings dict.
+    //
+    // Assumes settings are optional, and any values are strings.
+    const auto valueFromSettings =
+        [&managerSettings](const std::string_view key) -> std::optional<openassetio::Str> {
+      if (const auto& keyAndValue = managerSettings.find(openassetio::Str{key});
+          keyAndValue != cend(managerSettings)) {
+        return std::get<openassetio::Str>(keyAndValue->second);
+      }
+      return std::nullopt;
+    };
+
+    // Allow a configurable entity reference prefix.
+    if (const auto& maybeValue = valueFromSettings(kSettingsKeyForEntityRefPrefix)) {
+      entityReferencePrefix_ = *maybeValue;
+    }
+
+    // Support customisable capabilities. Assume a single-row CSV
+    // format.
+    if (const auto& maybeValue = valueFromSettings(kSettingsKeyForCapabilities)) {
+      std::istringstream csvRowAsStream{*maybeValue};
+      std::string capability;
+      // Loop over each listed capability.
+      while (std::getline(csvRowAsStream, capability, ',')) {
+        // Find the index of the capability by name.
+        if (const auto& iter = find(cbegin(kCapabilityNames), cend(kCapabilityNames), capability);
+            iter != cend(kCapabilityNames)) {
+          const std::size_t capabilityIdx = std::distance(cbegin(kCapabilityNames), iter);
+          // Update the capability set.
+          capabilities_.insert(static_cast<ManagerInterface::Capability>(capabilityIdx));
+        } else {
+          throw openassetio::errors::ConfigurationException(
+              "SimpleCppManager: unsupported capability: " + capability);
+        }
+      }
+    }
+
+    // For successful kRead managementPolicy queries, return the
+    // following policy trait alongside the queried traits.
+    if (const auto& maybeValue = valueFromSettings(kSettingsKeyForReadPolicy)) {
+      readPolicy_ = *maybeValue;
+    }
+
+    // The database of entities is specified as a CSV document.
+
+    if (const auto& maybeValue = valueFromSettings(kSettingsKeyForReadEntityTraitProperties)) {
+      // Loop over CSV document rows.
+      std::istringstream csvAsStream(*maybeValue);
+      std::string csvRow;
+      while (std::getline(csvAsStream, csvRow)) {
+        std::istringstream csvRowAsStream(csvRow);
+        std::string entityRef;
+        std::string traitId;
+        std::string propertyKey;
+        std::string propertyValue;
+        std::getline(csvRowAsStream, entityRef, ',');
+        std::getline(csvRowAsStream, traitId, ',');
+        std::getline(csvRowAsStream, propertyKey, ',');
+        std::getline(csvRowAsStream, propertyValue, ',');
+        Properties& properties = entityDatabase_[std::move(entityRef)][std::move(traitId)];
+        if (!propertyKey.empty()) {
+          properties[std::move(propertyKey)] = std::move(propertyValue);
+        }
+      }
+    }
+
+    // Update the stored settings dict.
+    settings_ = std::move(managerSettings);
+  }
+
+  [[nodiscard]] openassetio::InfoDictionary settings(
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    return settings_;
+  }
+
+  [[nodiscard]] openassetio::Str displayName() const override { return "Simple C++ Manager"; }
+
+  [[nodiscard]] bool hasCapability(const Capability capability) override {
+    return capabilities_.count(capability) != 0U;
+  }
+
+  /**
+   * Override to provide policy based on configuration.
+   *
+   * For each trait set in @p traitSets, determine if any entity in the
+   * database has all the traits in the set. If so, the corresponding
+   * entry in the result will be imbued with all those traits (excluding
+   * those without any properties), plus the policy trait (if
+   * configured). Otherwise, the entry will be empty.
+   *
+   * Only read access is supported.
+   */
+  [[nodiscard]] openassetio::trait::TraitsDatas managementPolicy(
+      const openassetio::trait::TraitSets& traitSets,
+      const openassetio::access::PolicyAccess policyAccess,
+      [[maybe_unused]] const openassetio::ContextConstPtr& context,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    namespace trait = openassetio::trait;
+
+    // Initialize the result with empty TraitsData entries.
+    trait::TraitsDatas result;
+    result.reserve(traitSets.size());
+    std::generate_n(std::back_inserter(result), traitSets.size(),
+                    [] { return trait::TraitsData::make(); });
+
+    // We only support read.
+    if (policyAccess != openassetio::access::PolicyAccess::kRead) {
+      return result;
+    }
+
+    // Helper function to determine if a trait set is a subset of the
+    // traits of an entity.
+    // NOLINTNEXTLINE(readability-identifier-naming)
+    static constexpr auto isSubsetOfEntityTraitSet = [](const trait::TraitSet& traitSet,
+                                                        const TraitProperties& entityTraits) {
+      return std::all_of(cbegin(traitSet), cend(traitSet),
+                         [&entityTraits](const trait::TraitId& desiredTraitId) {
+                           return entityTraits.count(desiredTraitId);
+                         });
+    };
+
+    // Loop over each trait set in the input batch.
+    for (std::size_t idx = 0; idx < traitSets.size(); ++idx) {
+      const trait::TraitSet& traitSet = traitSets[idx];
+
+      // An empty trait set is the least possible specificity, i.e.
+      // asking "do you manage everything?", which we don't.
+      if (traitSet.empty()) {
+        continue;
+      }
+
+      const trait::TraitsDataPtr& traitsData = result[idx];
+
+      for (const auto& entityRefAndTraits : entityDatabase_) {
+        const auto& entityTraits = entityRefAndTraits.second;
+        // If the entity has all the traits in the set, then this trait
+        // set is supported.
+
+        if (isSubsetOfEntityTraitSet(traitSet, entityTraits)) {
+          for (const trait::TraitId& traitId : traitSet) {
+            // We only imbue traits that have properties that can be
+            // `resolve`d.
+            if (!entityTraits.at(traitId).empty()) {
+              traitsData->addTrait(traitId);
+            }
+          }
+
+          // Policy traits can be used to communicate policy-specific
+          // information.
+          if (!readPolicy_.empty()) {
+            traitsData->addTrait(readPolicy_);
+          }
+
+          // We now know that at least one entity matches the given
+          // trait set. No need to continue searching, so exit the loop.
+          break;
+        }
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Override to check string based on configured prefix.
+   *
+   * Prefix must be provided by the "prefix" setting.
+   */
+  [[nodiscard]] bool isEntityReferenceString(
+      const openassetio::Str& someString,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    return someString.rfind(entityReferencePrefix_, 0) == 0;
+  }
+
+  /**
+   * Override to retrieve the traits of provided entities from the
+   * database.
+   *
+   * Only read access is supported.
+   */
+  void entityTraits(const openassetio::EntityReferences& entityReferences,
+                    const openassetio::access::EntityTraitsAccess entityTraitsAccess,
+                    [[maybe_unused]] const openassetio::ContextConstPtr& context,
+                    [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+                    const EntityTraitsSuccessCallback& successCallback,
+                    const BatchElementErrorCallback& errorCallback) override {
+    namespace trait = openassetio::trait;
+    using openassetio::errors::BatchElementError;
+
+    // We only support read access.
+    if (entityTraitsAccess != openassetio::access::EntityTraitsAccess::kRead) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        errorCallback(idx, BatchElementError{BatchElementError::ErrorCode::kEntityAccessError,
+                                             "Entity access is read-only"});
+      }
+      return;
+    }
+
+    // Loop each entity reference in the input batch.
+    for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+      const openassetio::EntityReference& entityReference = entityReferences[idx];
+
+      // Find the entity reference in the database.
+      if (const auto entityRefAndTraits = entityDatabase_.find(entityReference.toString());
+          entityRefAndTraits != cend(entityDatabase_)) {
+        const TraitProperties& entityTraits = entityRefAndTraits->second;
+        // Construct the trait set for the entity.
+        trait::TraitSet traitSet;
+        std::transform(
+            cbegin(entityTraits), cend(entityTraits), std::inserter(traitSet, end(traitSet)),
+            [](const auto& traitIdAndProperties) { return traitIdAndProperties.first; });
+        successCallback(idx, std::move(traitSet));
+      } else {
+        // If we can't find the entity reference in the database, then
+        // flag an error.
+        errorCallback(idx, BatchElementError{BatchElementError::ErrorCode::kEntityResolutionError,
+                                             "Entity not found"});
+      }
+    }
+  }
+
+  /**
+   * Override to retrieve the properties of provided entities from the
+   * database.
+   *
+   * Only read access is supported.
+   */
+  void resolve(const openassetio::EntityReferences& entityReferences,
+               const openassetio::trait::TraitSet& traitSet,
+               const openassetio::access::ResolveAccess resolveAccess,
+               [[maybe_unused]] const openassetio::ContextConstPtr& context,
+               [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+               const ResolveSuccessCallback& successCallback,
+               const BatchElementErrorCallback& errorCallback) override {
+    namespace trait = openassetio::trait;
+    using openassetio::errors::BatchElementError;
+
+    // We only support read access.
+    if (resolveAccess != openassetio::access::ResolveAccess::kRead) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        errorCallback(idx, BatchElementError{BatchElementError::ErrorCode::kEntityAccessError,
+                                             "Entity access is read-only"});
+      }
+      return;
+    }
+
+    // Loop each entity reference in the input batch.
+    for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+      const openassetio::EntityReference& entityReference = entityReferences[idx];
+
+      // Find the entity reference in the database.
+      if (const auto& entityRefAndTraits = entityDatabase_.find(entityReference.toString());
+          entityRefAndTraits != cend(entityDatabase_)) {
+        const TraitProperties& traitIdToProperties = entityRefAndTraits->second;
+        trait::TraitsDataPtr traitsData = trait::TraitsData::make();
+
+        // Set the properties for the traits, converting from str to
+        // numeric/boolean as necessary.
+        for (const trait::TraitId& traitId : traitSet) {
+          // Check if the entity has the requested trait.
+          if (const auto& traitIdAndProperties = traitIdToProperties.find(traitId);
+              traitIdAndProperties != cend(traitIdToProperties)) {
+            const Properties& traitProperties = traitIdAndProperties->second;
+            // Set all properties for the trait. Note that we rely on
+            // this to implicitly imbue the trait, meaning the trait
+            // remains unimbued if it has no associated properties.
+            for (const auto& [propertyKey, propertyValueAsStr] : traitProperties) {
+              traitsData->setTraitProperty(traitId, propertyKey,
+                                           strToPropertyValue(propertyValueAsStr));
+            }
+          }
+        }
+
+        successCallback(idx, std::move(traitsData));
+      } else {
+        // If we can't find the entity reference in the database, then
+        // flag an error.
+        errorCallback(idx, BatchElementError{BatchElementError::ErrorCode::kEntityResolutionError,
+                                             "Entity not found"});
+      }
+    }
+  }
+
+  //////////////////////////////////////////////////////////////////////
+  /*
+   * The following methods either call the base class implementation or
+   * return a stub response, depending on the configured capabilities.
+   *
+   * Note that the base class implementation will throw a
+   * `NotImplementedException`.
+   */
+
+  [[nodiscard]] openassetio::StrMap updateTerminology(
+      [[maybe_unused]] openassetio::StrMap terms,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    if (hasCapability(Capability::kCustomTerminology)) {
+      return {};
+    }
+    return ManagerInterface::updateTerminology(std::move(terms), hostSession);
+  }
+  [[nodiscard]] openassetio::managerApi::ManagerStateBasePtr createState(
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    if (hasCapability(Capability::kStatefulContexts)) {
+      return {};
+    }
+    return ManagerInterface::createState(hostSession);
+  }
+  [[nodiscard]] openassetio::managerApi::ManagerStateBasePtr createChildState(
+      [[maybe_unused]] const openassetio::managerApi::ManagerStateBasePtr& parentState,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    if (hasCapability(Capability::kStatefulContexts)) {
+      return {};
+    }
+    return ManagerInterface::createChildState(parentState, hostSession);
+  }
+  [[nodiscard]] openassetio::Str persistenceTokenForState(
+      [[maybe_unused]] const openassetio::managerApi::ManagerStateBasePtr& state,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    if (hasCapability(Capability::kStatefulContexts)) {
+      return "a";
+    }
+    return ManagerInterface::persistenceTokenForState(state, hostSession);
+  }
+  [[nodiscard]] openassetio::managerApi::ManagerStateBasePtr stateFromPersistenceToken(
+      [[maybe_unused]] const openassetio::Str& token,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+    if (hasCapability(Capability::kStatefulContexts)) {
+      return {};
+    }
+    return ManagerInterface::stateFromPersistenceToken(token, hostSession);
+  }
+  void entityExists(const openassetio::EntityReferences& entityReferences,
+                    [[maybe_unused]] const openassetio::ContextConstPtr& context,
+                    [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+                    const ExistsSuccessCallback& successCallback,
+                    [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kExistenceQueries)) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        successCallback(idx, false);
+      }
+    } else {
+      ManagerInterface::entityExists(entityReferences, context, hostSession, successCallback,
+                                     errorCallback);
+    }
+  }
+  void defaultEntityReference(
+      const openassetio::trait::TraitSets& traitSets,
+      [[maybe_unused]] const openassetio::access::DefaultEntityAccess defaultEntityAccess,
+      [[maybe_unused]] const openassetio::ContextConstPtr& context,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+      const DefaultEntityReferenceSuccessCallback& successCallback,
+      [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kDefaultEntityReferences)) {
+      for (std::size_t idx = 0; idx < traitSets.size(); ++idx) {
+        successCallback(idx, openassetio::EntityReference(entityReferencePrefix_));
+      }
+    } else {
+      ManagerInterface::defaultEntityReference(traitSets, defaultEntityAccess, context,
+                                               hostSession, successCallback, errorCallback);
+    }
+  }
+  void getWithRelationship(
+      const openassetio::EntityReferences& entityReferences,
+      [[maybe_unused]] const openassetio::trait::TraitsDataPtr& relationshipTraitsData,
+      [[maybe_unused]] const openassetio::trait::TraitSet& resultTraitSet,
+      [[maybe_unused]] const size_t pageSize,
+      [[maybe_unused]] const openassetio::access::RelationsAccess relationsAccess,
+      [[maybe_unused]] const openassetio::ContextConstPtr& context,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+      const RelationshipQuerySuccessCallback& successCallback,
+      [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kRelationshipQueries)) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        successCallback(idx, std::make_shared<StubPager>());
+      }
+    } else {
+      ManagerInterface::getWithRelationship(entityReferences, relationshipTraitsData,
+                                            resultTraitSet, pageSize, relationsAccess, context,
+                                            hostSession, successCallback, errorCallback);
+    }
+  }
+  void getWithRelationships(
+      [[maybe_unused]] const openassetio::EntityReference& entityReference,
+      const openassetio::trait::TraitsDatas& relationshipTraitsDatas,
+      [[maybe_unused]] const openassetio::trait::TraitSet& resultTraitSet,
+      [[maybe_unused]] const size_t pageSize,
+      [[maybe_unused]] const openassetio::access::RelationsAccess relationsAccess,
+      [[maybe_unused]] const openassetio::ContextConstPtr& context,
+      [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+      const RelationshipQuerySuccessCallback& successCallback,
+      [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kRelationshipQueries)) {
+      for (std::size_t idx = 0; idx < relationshipTraitsDatas.size(); ++idx) {
+        successCallback(idx, std::make_shared<StubPager>());
+      }
+    } else {
+      ManagerInterface::getWithRelationships(entityReference, relationshipTraitsDatas,
+                                             resultTraitSet, pageSize, relationsAccess, context,
+                                             hostSession, successCallback, errorCallback);
+    }
+  }
+  void preflight(const openassetio::EntityReferences& entityReferences,
+                 [[maybe_unused]] const openassetio::trait::TraitsDatas& traitsHints,
+                 [[maybe_unused]] const openassetio::access::PublishingAccess publishingAccess,
+                 [[maybe_unused]] const openassetio::ContextConstPtr& context,
+                 [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+                 const PreflightSuccessCallback& successCallback,
+                 [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kPublishing)) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        successCallback(idx, entityReferences[idx]);
+      }
+    } else {
+      ManagerInterface::preflight(entityReferences, traitsHints, publishingAccess, context,
+                                  hostSession, successCallback, errorCallback);
+    }
+  }
+  void register_(const openassetio::EntityReferences& entityReferences,
+                 [[maybe_unused]] const openassetio::trait::TraitsDatas& entityTraitsDatas,
+                 [[maybe_unused]] const openassetio::access::PublishingAccess publishingAccess,
+                 [[maybe_unused]] const openassetio::ContextConstPtr& context,
+                 [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession,
+                 const RegisterSuccessCallback& successCallback,
+                 [[maybe_unused]] const BatchElementErrorCallback& errorCallback) override {
+    if (hasCapability(Capability::kPublishing)) {
+      for (std::size_t idx = 0; idx < entityReferences.size(); ++idx) {
+        successCallback(idx, entityReferences[idx]);
+      }
+    } else {
+      ManagerInterface::register_(entityReferences, entityTraitsDatas, publishingAccess, context,
+                                  hostSession, successCallback, errorCallback);
+    }
+  }
+
+ private:
+  /**
+   * Helper function to convert a string to a property value, i.e. a
+   * variant of int, float, bool, or string.
+   *
+   * When using std::stringstream, it is insufficient to check that
+   * conversion succeeds - we must also ensure the whole input is
+   * consumed. E.g. 123.4 will parse as an int and leave the .4
+   * unconsumed. Detecting that we've consumed the input using .eof()
+   * doesn't work for boolean conversions (because it doesn't consume to
+   * eof), and using .tellg() doesn't work for numeric conversions
+   * (because it consumes to eof). Luckily .peek() works in all cases.
+   *
+   * In order to fully reset the state of the stringstream between
+   * conversion attempts, it's easiest just to reconstruct it from
+   * scratch.
+   */
+  static openassetio::trait::property::Value strToPropertyValue(const std::string& valueAsString) {
+    static constexpr auto kEof = std::stringstream::traits_type::eof();
+    std::stringstream converter;
+
+    // Attempt integer conversion.
+    converter = std::stringstream{valueAsString};
+    if (openassetio::Int result; (converter >> result) && (converter.peek() == kEof)) {
+      return result;
+    }
+
+    // Attempt float conversion.
+    converter = std::stringstream{valueAsString};
+    if (openassetio::Float result; (converter >> result) && (converter.peek() == kEof)) {
+      return result;
+    }
+
+    // Attempt boolean conversion (from "true"/"false" strings).
+    converter = std::stringstream{valueAsString};
+    if (openassetio::Bool result;
+        (converter >> std::boolalpha >> result) && (converter.peek() == kEof)) {
+      return result;
+    }
+
+    // Assume it's just a string.
+    return valueAsString;
+  };
+
+  /**
+   * Stub pager that always returns an empty list of entity references.
+   *
+   * Required for the getWithRelationship(s) methods.
+   */
+  struct StubPager final : openassetio::managerApi::EntityReferencePagerInterface {
+    void close(
+        [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {}
+    [[nodiscard]] bool hasNext(
+        [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+      return false;
+    }
+    [[nodiscard]] openassetio::EntityReferences get(
+        [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {
+      return {};
+    }
+    void next(
+        [[maybe_unused]] const openassetio::managerApi::HostSessionPtr& hostSession) override {}
+  };
+
+  /// Settings dictionary as provided to `initialize`.
+  openassetio::InfoDictionary settings_;
+
+  /**
+   * Capabilities that are supported by this manager.
+   *
+   * The default set here is the only properly implemented (i.e.
+   * non-stub) functionality. Capabilities can be toggled using the
+   * "capabilities" key in @ref settings_.
+   */
+  std::unordered_set<ManagerInterface::Capability> capabilities_{
+      Capability::kEntityReferenceIdentification, Capability::kManagementPolicyQueries,
+      Capability::kEntityTraitIntrospection, Capability::kResolution};
+
+  /**
+   * Key-value properties for entities and their traits.
+   *
+   * The string value will be coerced to the appropriate type when
+   * the property is resolved.
+   */
+  using Properties = std::unordered_map<std::string, std::string>;
+  /// Map of trait IDs to properties.
+  using TraitProperties = std::unordered_map<std::string, Properties>;
+  /// Map of entity references to trait IDs and their properties.
+  using EntityTraitProperties = std::unordered_map<std::string, TraitProperties>;
+
+  /// The entity database.
+  EntityTraitProperties entityDatabase_;
+
+  /// Prefix for entity references. Used in @ref
+  /// isEntityReferenceString.
+  std::string entityReferencePrefix_{"simplecpp://"};
+
+  /// Additional policy trait to imbue in the response to successful
+  /// managementPolicy queries.
+  openassetio::trait::TraitId readPolicy_;
+};
+
+/**
+ * Subclass of the CppPluginSystemManagerPlugin that can be used to
+ * construct instances of our simple ManagerInterface.
+ */
+struct Plugin final : openassetio::pluginSystem::CppPluginSystemManagerPlugin {
+  [[nodiscard]] openassetio::Identifier identifier() const override {
+    return openassetio::Identifier{kPluginId};
+  }
+  openassetio::managerApi::ManagerInterfacePtr interface() override {
+    return std::make_shared<SimpleCppManagerInterface>();
+  }
+};
+
+extern "C" {
+
+/**
+ * External entry point that the OpenAssetIO plugin system will query.
+ *
+ * For cross-platform compatibility there are a few layers of
+ * indirection in loading a plugin. First, this C linkage function is
+ * called, which returns a factory function. The factory, when called,
+ * returns a reference to a generic plugin object. The plugin object is
+ * a subclass instance that provides methods for creating a manager
+ * interface.
+ *
+ * @return A lambda that will create an instance of a generic plugin
+ * object.
+ */
+OPENASSETIO_EXAMPLE_SIMPLECPPMANAGER_EXPORT
+openassetio::pluginSystem::PluginFactory openassetioPlugin() noexcept {
+  return []() noexcept -> openassetio::pluginSystem::CppPluginSystemPluginPtr {
+    return std::make_shared<Plugin>();
+  };
+}
+}


Test patch:
diff --git a/examples/host/simpleResolver/test_simpleResolver.py b/examples/host/simpleResolver/test_simpleResolver.py
index a2ba186db..aa0125962 100644
--- a/examples/host/simpleResolver/test_simpleResolver.py
+++ b/examples/host/simpleResolver/test_simpleResolver.py
@@ -33,7 +33,7 @@
 
 
 class Test_simpleResolver_errors:
-    def test_when_lib_initialization_fails_then_error_written_to_stderr_and_return_code_non_zero(
+    def test_when_no_config_then_error_written_to_stderr_and_return_code_non_zero(
         self, monkeypatch
     ):
         monkeypatch.delenv("OPENASSETIO_DEFAULT_CONFIG", raising=False)
@@ -45,6 +45,18 @@ def test_when_lib_initialization_fails_then_error_written_to_stderr_and_return_c
         assert result.stderr.splitlines() == expected_message
         assert result.returncode == 1
 
+    def test_when_bad_config_then_error_written_to_stderr_and_return_code_non_zero(
+        self, monkeypatch
+    ):
+        monkeypatch.setenv("OPENASSETIO_DEFAULT_CONFIG", "/some/bad/path")
+        result = execute_cli()
+        expected_message = [
+            "ERROR: Could not load default manager config from '/some/bad/path', file does not"
+            " exist."
+        ]
+        assert result.stderr.splitlines() == expected_message
+        assert result.returncode == 1
+
     def test_when_env_set_and_no_args_then_usage_printed_and_return_code_is_one(
         self, test_config_env  # pylint: disable=unused-argument
     ):
diff --git a/examples/manager/SimpleCppManager/tests/CMakeLists.txt b/examples/manager/SimpleCppManager/tests/CMakeLists.txt
new file mode 100644
index 000000000..184179aec
--- /dev/null
+++ b/examples/manager/SimpleCppManager/tests/CMakeLists.txt
@@ -0,0 +1,62 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2024 The Foundry Visionmongers Ltd
+
+# Detect the best Python interpreter to use for running pytest.
+# 1. If OPENASSETIO_PYTHON_EXE is defined, use that. This will be set
+#    by the parent OpenAssetIO project.
+# 2. Otherwise, use the external environment's python.
+if (NOT DEFINED OPENASSETIO_PYTHON_EXE)
+    set(OPENASSETIO_PYTHON_EXE python)
+endif ()
+
+# Construct list of environment variables to use for tests.
+
+if (DEFINED OPENASSETIO_PYTHON_SITEDIR)
+    # E.g. we're running tests via OpenAssetIO parent project, so we
+    # know the PYTHONPATH we need to use.
+    list(APPEND _envmod --modify "PYTHONPATH=path_list_append:${CMAKE_INSTALL_PREFIX}/${OPENASSETIO_PYTHON_SITEDIR}")
+endif ()
+
+list(APPEND _envmod
+    "OPENASSETIO_PLUGIN_PATH=${CMAKE_INSTALL_PREFIX}/${OPENASSETIO_SIMPLECPPMANAGER_INSTALL_SUBDIR}")
+
+list(APPEND _envmod
+    "OPENASSETIO_DEFAULT_CONFIG=${CMAKE_CURRENT_SOURCE_DIR}/resources/openassetio_config.toml")
+
+list(APPEND _envmod "OPENASSETIO_LOGGING_SEVERITY=1")
+
+# Add pytest test
+add_test(
+    NAME openassetio.example.${PROJECT_NAME}.pytest
+    COMMAND
+    ${CMAKE_COMMAND} -E env ${_envmod}
+    ${OPENASSETIO_PYTHON_EXE} -m pytest -v --capture=tee-sys ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+# Add openassetio.test.manager API compliance suite test.
+add_test(
+    NAME openassetio.example.${PROJECT_NAME}.apiCompliance
+    COMMAND
+    ${CMAKE_COMMAND} -E env ${_envmod}
+    ${OPENASSETIO_PYTHON_EXE} -m openassetio.test.manager -f ${CMAKE_CURRENT_SOURCE_DIR}/resources/fixtures.py
+)
+
+# Check if we're running as part of OpenAssetIO's build. Ideally we'd
+# use `if (TEST` here, but that only works in the same or parent
+# directories where the `install` fixture was defined, apparently.
+if (TARGET openassetio.internal.install)
+    # We're running as part of OpenAssetIO's build, so ensure it is
+    # built and installed before running the tests.
+    set_tests_properties(
+        openassetio.example.${PROJECT_NAME}.pytest
+        PROPERTIES
+        FIXTURES_REQUIRED openassetio.internal.install
+        LABELS Test
+    )
+    set_tests_properties(
+        openassetio.example.${PROJECT_NAME}.apiCompliance
+        PROPERTIES
+        FIXTURES_REQUIRED openassetio.internal.install
+        LABELS Test
+    )
+endif ()
diff --git a/examples/manager/SimpleCppManager/tests/resources/fixtures.py b/examples/manager/SimpleCppManager/tests/resources/fixtures.py
new file mode 100644
index 000000000..bd8896d8c
--- /dev/null
+++ b/examples/manager/SimpleCppManager/tests/resources/fixtures.py
@@ -0,0 +1,72 @@
+#
+#   Copyright 2013-2022 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Manager test harness test case fixtures for openassetio.test.manager
+API compliance test suite.
+"""
+
+# pylint: disable=all
+
+kIdentifier = "org.openassetio.examples.manager.simplecppmanager"
+
+fixtures = {
+    "identifier": kIdentifier,
+    "settings": {
+        "prefix": "simplecpp://",
+        "read_policy": "openassetio-mediacreation:managementPolicy.Managed",
+        "read_traits": """
+simplecpp://test/entity/1,openassetio-mediacreation:usage.Entity
+simplecpp://test/entity/1,openassetio-mediacreation:content.LocatableContent,location,file:///tmp/test1.txt
+simplecpp://test/entity/1,openassetio-mediacreation:identity.DisplayName,name,Test Entity 1
+""",
+    },
+    "Test_info": {"test_matches_fixture": {"info": {}}},
+    "Test_identifier": {"test_matches_fixture": {"identifier": kIdentifier}},
+    "Test_displayName": {"test_matches_fixture": {"display_name": "Simple C++ Manager"}},
+    "Test_isEntityReferenceString": {
+        "shared": {
+            "a_valid_reference": "simplecpp://😀",
+            "an_invalid_reference": "implecpp://😀",
+        }
+    },
+    "Test_entityTraits": {
+        "test_when_querying_missing_reference_for_read_then_resolution_error_is_returned": {
+            "a_reference_to_a_missing_entity": "simplecpp://😀",
+            "expected_error_message": "Entity not found",
+        },
+        "test_when_read_only_entity_queried_for_write_then_access_error_is_returned": {
+            "a_reference_to_a_readonly_entity": "simplecpp://test/entity/1",
+            "expected_error_message": "Entity access is read-only",
+        },
+    },
+    "Test_resolve": {
+        "shared": {
+            "a_reference_to_a_readable_entity": "simplecpp://test/entity/1",
+            "a_set_of_valid_traits": {
+                "openassetio-mediacreation:identity.DisplayName",
+                "openassetio-mediacreation:content.LocatableContent",
+            },
+        },
+        "test_when_resolving_read_only_reference_for_publish_then_access_error_is_returned": {
+            "a_reference_to_a_readonly_entity": "simplecpp://test/entity/1",
+            "the_error_string_for_a_reference_to_a_readonly_entity": "Entity access is read-only",
+        },
+        "test_when_resolving_missing_reference_then_resolution_error_is_returned": {
+            "a_reference_to_a_missing_entity": "simplecpp://😀",
+            "the_error_string_for_a_reference_to_a_missing_entity": "Entity not found",
+        },
+    },
+}
diff --git a/examples/manager/SimpleCppManager/tests/resources/openassetio_config.toml b/examples/manager/SimpleCppManager/tests/resources/openassetio_config.toml
new file mode 100644
index 000000000..a0ca1ed22
--- /dev/null
+++ b/examples/manager/SimpleCppManager/tests/resources/openassetio_config.toml
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2024 The Foundry Visionmongers Ltd
+
+[manager]
+identifier = "org.openassetio.examples.manager.simplecppmanager"
+
+[manager.settings]
+
+# Prefix to be used for all entity references. Used by
+# `isEntityReference`.
+prefix = "simplecpp://"
+
+# By default, only required capabilities plus "resolution" are
+# supported. Enabling other capabilities will provide stub
+# implementations for their associated functions. The following are the
+# default set of capabilities:
+# capabilities="entityReferenceIdentification,managementPolicyQueries,resolution,entityTraitIntrospection"
+# The available capabilities are:
+# capabilities="entityReferenceIdentification,managementPolicyQueries,statefulContexts,customTerminology,resolution,publishing,relationshipQueries,existenceQueries,defaultEntityReferences,entityTraitIntrospection"
+
+# Additional trait to imbue when querying managementPolcy with `kRead`
+# access mode, as long as at least one entity (below) satisfies the
+# provided trait set.
+read_policy = "openassetio-mediacreation:managementPolicy.Managed"
+
+# Mapping of entity references to trait properties. This is the
+# "database" to be used by the manager. Used by `resolve` with a `kRead`
+# access mode.
+#
+# The entity reference and trait ID is required. A blank property key
+# field indicates the trait has no properties.
+read_traits = '''
+simplecpp://test/entity/1,openassetio-mediacreation:usage.Entity
+simplecpp://test/entity/1,openassetio-mediacreation:content.LocatableContent,location,file:///tmp/test1.txt
+simplecpp://test/entity/1,openassetio-mediacreation:identity.DisplayName,name,Test Entity 1
+simplecpp://test/entity/2,openassetio-mediacreation:usage.Entity,,
+simplecpp://test/entity/2,openassetio-mediacreation:content.LocatableContent,location,file:///tmp/test2.txt
+simplecpp://test/entity/2,openassetio-mediacreation:content.LocatableContent,isTemplated,false
+simplecpp://test/entity/2,openassetio-mediacreation:timeDomain.FrameRanged,startFrame,123
+simplecpp://test/entity/2,openassetio-mediacreation:timeDomain.FrameRanged,framesPerSecond,123.4
+'''
diff --git a/examples/manager/SimpleCppManager/tests/test_SimpleCppManager.py b/examples/manager/SimpleCppManager/tests/test_SimpleCppManager.py
new file mode 100644
index 000000000..b91bfdf93
--- /dev/null
+++ b/examples/manager/SimpleCppManager/tests/test_SimpleCppManager.py
@@ -0,0 +1,402 @@
+#
+#   Copyright 2024 The Foundry Visionmongers Ltd
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Tests for the SimpleCppManager.
+
+Note that SimpleCppManager is data-driven and tests assume a particular
+configuration. See resources/openassetio_config.toml.
+"""
+import operator
+
+# pylint: disable=invalid-name,redefined-outer-name
+# pylint: disable=missing-class-docstring,missing-function-docstring
+
+import os
+import pathlib
+
+import pytest
+
+
+from openassetio.trait import TraitsData
+from openassetio import access, Context, errors, EntityReference
+from openassetio import managerApi
+from openassetio.hostApi import HostInterface, ManagerFactory, Manager
+from openassetio.pluginSystem import CppPluginSystemManagerImplementationFactory
+from openassetio.log import ConsoleLogger
+
+
+class Test_SimpleCppManager_initialize:
+    class StubState(managerApi.ManagerStateBase):
+        pass
+
+    def test_when_default_capability_then_default_capability_set_available(
+        self, a_fresh_simple_cpp_manager, a_context
+    ):
+        expected_capability_map = {
+            Manager.Capability.kStatefulContexts: False,
+            Manager.Capability.kCustomTerminology: False,
+            Manager.Capability.kResolution: True,
+            Manager.Capability.kPublishing: False,
+            Manager.Capability.kRelationshipQueries: False,
+            Manager.Capability.kExistenceQueries: False,
+            Manager.Capability.kDefaultEntityReferences: False,
+        }
+
+        for capability, expected_value in expected_capability_map.items():
+            assert a_fresh_simple_cpp_manager.hasCapability(capability) == expected_value
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.updateTerminology({})
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.preflight([], [], access.PublishingAccess.kWrite, a_context)
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.register([], [], access.PublishingAccess.kWrite, a_context)
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.getWithRelationship(
+                [], TraitsData(), 1, access.RelationsAccess.kRead, a_context, set()
+            )
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.getWithRelationships(
+                EntityReference(""),
+                [],
+                1,
+                access.RelationsAccess.kRead,
+                a_context,
+                set(),
+            )
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.entityExists([], a_context)
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.defaultEntityReference(
+                [],
+                access.DefaultEntityAccess.kRead,
+                a_context,
+                lambda *a: 0,
+                lambda *a: 0,
+            )
+
+        with pytest.raises(errors.NotImplementedException):
+            a_context.managerState = self.StubState()
+            a_fresh_simple_cpp_manager.persistenceTokenForContext(a_context)
+
+        with pytest.raises(errors.NotImplementedException):
+            a_fresh_simple_cpp_manager.contextFromPersistenceToken("abc")
+
+    def test_when_capability_is_overridden_then_stub_implementations_available(
+        self, a_fresh_simple_cpp_manager, a_context
+    ):
+
+        settings = a_fresh_simple_cpp_manager.settings()
+        settings["capabilities"] = (
+            "entityReferenceIdentification,managementPolicyQueries,statefulContexts,"
+            "customTerminology,resolution,publishing,relationshipQueries,existenceQueries,"
+            "defaultEntityReferences,entityTraitIntrospection"
+        )
+        a_fresh_simple_cpp_manager.initialize(settings)
+
+        expected_capability_map = {
+            Manager.Capability.kStatefulContexts: True,
+            Manager.Capability.kCustomTerminology: True,
+            Manager.Capability.kResolution: True,
+            Manager.Capability.kPublishing: True,
+            Manager.Capability.kRelationshipQueries: True,
+            Manager.Capability.kExistenceQueries: True,
+            Manager.Capability.kDefaultEntityReferences: True,
+        }
+
+        for capability, expected_value in expected_capability_map.items():
+            assert a_fresh_simple_cpp_manager.hasCapability(capability) == expected_value
+
+        assert a_fresh_simple_cpp_manager.updateTerminology({}) == {}
+
+        assert a_fresh_simple_cpp_manager.preflight(
+            EntityReference("blah"),
+            TraitsData(),
+            access.PublishingAccess.kWrite,
+            a_context,
+        ) == EntityReference("blah")
+
+        assert a_fresh_simple_cpp_manager.register(
+            EntityReference("blah"),
+            TraitsData(),
+            access.PublishingAccess.kWrite,
+            a_context,
+        ) == EntityReference("blah")
+
+        assert (
+            a_fresh_simple_cpp_manager.getWithRelationship(
+                EntityReference("blah"),
+                TraitsData(),
+                1,
+                access.RelationsAccess.kRead,
+                a_context,
+                set(),
+            )
+            is not None
+        )
+
+        assert (
+            a_fresh_simple_cpp_manager.getWithRelationships(
+                EntityReference(""),
+                [TraitsData()],
+                1,
+                access.RelationsAccess.kRead,
+                a_context,
+                set(),
+            )
+            is not None
+        )
+
+        assert a_fresh_simple_cpp_manager.entityExists(EntityReference("blah"), a_context) is False
+
+        default_refs = [None]
+
+        a_fresh_simple_cpp_manager.defaultEntityReference(
+            [set()],
+            access.DefaultEntityAccess.kRead,
+            a_context,
+            lambda idx, ref: operator.setitem(default_refs, idx, ref),
+            lambda *a: pytest.fail("Should not be called"),
+        )
+        assert default_refs == [EntityReference("simplecpp://")]
+
+    def test_when_prefix_overridden_then_entity_reference_format_changed(
+        self, a_fresh_simple_cpp_manager
+    ):
+        assert a_fresh_simple_cpp_manager.isEntityReferenceString("simplecpp://😀")
+        assert not a_fresh_simple_cpp_manager.isEntityReferenceString("somethingelse@😀")
+
+        settings = a_fresh_simple_cpp_manager.settings()
+        settings["prefix"] = "somethingelse@"
+        a_fresh_simple_cpp_manager.initialize(settings)
+
+        assert not a_fresh_simple_cpp_manager.isEntityReferenceString("simplecpp://@😀")
+        assert a_fresh_simple_cpp_manager.isEntityReferenceString("somethingelse@😀")
+
+    def test_when_capability_doesnt_exist_then_ConfigurationException_raised(
+        self, a_fresh_simple_cpp_manager
+    ):
+        settings = a_fresh_simple_cpp_manager.settings()
+        settings["capabilities"] = "non_existent_capability"
+        with pytest.raises(errors.ConfigurationException):
+            a_fresh_simple_cpp_manager.initialize(settings)
+
+
+class Test_SimpleCppManager_isEntityReferenceString:
+    def test_when_not_a_reference_then_returns_false(self, a_simple_cpp_manager):
+        assert not a_simple_cpp_manager.isEntityReferenceString("not a reference")
+
+    def test_when_is_a_reference_then_returns_true(self, a_simple_cpp_manager):
+        assert a_simple_cpp_manager.isEntityReferenceString("simplecpp://😀")
+
+
+class Test_SimpleCppManager_managementPolicy:
+    def test_when_has_entity_trait_then_policy_contains_policy_trait_and_entity_trait(
+        self, a_simple_cpp_manager: Manager, a_context: Context
+    ):
+        expected_policies = [
+            TraitsData(),
+            TraitsData({"openassetio-mediacreation:managementPolicy.Managed"}),
+            TraitsData(
+                {
+                    "openassetio-mediacreation:content.LocatableContent",
+                    "openassetio-mediacreation:managementPolicy.Managed",
+                }
+            ),
+            TraitsData(
+                {
+                    "openassetio-mediacreation:content.LocatableContent",
+                    "openassetio-mediacreation:managementPolicy.Managed",
+                }
+            ),
+            TraitsData(
+                {
+                    "openassetio-mediacreation:identity.DisplayName",
+                    "openassetio-mediacreation:managementPolicy.Managed",
+                }
+            ),
+            TraitsData(
+                {
+                    "openassetio-mediacreation:content.LocatableContent",
+                    "openassetio-mediacreation:identity.DisplayName",
+                    "openassetio-mediacreation:managementPolicy.Managed",
+                }
+            ),
+        ]
+
+        actual_policies = a_simple_cpp_manager.managementPolicy(
+            [
+                set(),
+                {"openassetio-mediacreation:usage.Entity"},
+                {
+                    "openassetio-mediacreation:usage.Entity",
+                    "openassetio-mediacreation:content.LocatableContent",
+                },
+                {"openassetio-mediacreation:content.LocatableContent"},
+                {"openassetio-mediacreation:identity.DisplayName"},
+                {
+                    "openassetio-mediacreation:content.LocatableContent",
+                    "openassetio-mediacreation:identity.DisplayName",
+                },
+            ],
+            access.PolicyAccess.kRead,
+            a_context,
+        )
+
+        assert expected_policies == actual_policies
+
+    @pytest.mark.parametrize(
+        "policy_access",
+        [
+            access.PolicyAccess.kWrite,
+            access.PolicyAccess.kManagerDriven,
+            access.PolicyAccess.kCreateRelated,
+        ],
+    )
+    def test_when_non_read_policy_then_response_is_empty(
+        self, a_simple_cpp_manager, a_context, policy_access
+    ):
+        [policy] = a_simple_cpp_manager.managementPolicy(
+            [{"openassetio-mediacreation:content.LocatableContent"}],
+            policy_access,
+            a_context,
+        )
+
+        assert policy == TraitsData()
+
+
+class Test_SimpleCppManager_entityTraits:
+    def test_when_entity_doesnt_exist_then_EntityResolutionError(
+        self, a_simple_cpp_manager, a_context
+    ):
+        an_entity_reference = a_simple_cpp_manager.createEntityReference("simplecpp://😀")
+
+        with pytest.raises(errors.BatchElementException) as err:
+            a_simple_cpp_manager.entityTraits(
+                an_entity_reference, access.EntityTraitsAccess.kRead, a_context
+            )
+
+        assert err.value.error.code == errors.BatchElementError.ErrorCode.kEntityResolutionError
+
+    def test_when_non_read_access_then_EntityAccessError(self, a_simple_cpp_manager, a_context):
+        an_entity_reference = a_simple_cpp_manager.createEntityReference(
+            "simplecpp://test/entity/1"
+        )
+        with pytest.raises(errors.BatchElementException) as err:
+            a_simple_cpp_manager.entityTraits(
+                [an_entity_reference, an_entity_reference],
+                access.EntityTraitsAccess.kWrite,
+                a_context,
+            )
+
+        assert err.value.error.code == errors.BatchElementError.ErrorCode.kEntityAccessError
+
+    def test_when_entity_exists_for_read_access_then_returns_entity_traits(
+        self, a_simple_cpp_manager, a_context
+    ):
+        an_entity_reference = a_simple_cpp_manager.createEntityReference(
+            "simplecpp://test/entity/1"
+        )
+        expected_entity_traits = {
+            "openassetio-mediacreation:usage.Entity",
+            "openassetio-mediacreation:identity.DisplayName",
+            "openassetio-mediacreation:content.LocatableContent",
+        }
+        actual_entity_traits = a_simple_cpp_manager.entityTraits(
+            an_entity_reference, access.EntityTraitsAccess.kRead, a_context
+        )
+
+        assert expected_entity_traits == actual_entity_traits
+
+
+class Test_SimpleCppManager_resolve:
+    def test_when_values_resolved_then_value_types_are_correct(
+        self, a_simple_cpp_manager, a_context
+    ):
+        an_entity_reference = a_simple_cpp_manager.createEntityReference(
+            "simplecpp://test/entity/2"
+        )
+
+        expeted_trait_data = TraitsData()
+        expeted_trait_data.setTraitProperty(
+            "openassetio-mediacreation:content.LocatableContent",
+            "location",
+            "file:///tmp/test2.txt",
+        )
+        expeted_trait_data.setTraitProperty(
+            "openassetio-mediacreation:content.LocatableContent",
+            "isTemplated",
+            False,
+        )
+        expeted_trait_data.setTraitProperty(
+            "openassetio-mediacreation:timeDomain.FrameRanged", "startFrame", 123
+        )
+        expeted_trait_data.setTraitProperty(
+            "openassetio-mediacreation:timeDomain.FrameRanged", "framesPerSecond", 123.4
+        )
+
+        actual_trait_data = a_simple_cpp_manager.resolve(
+            an_entity_reference,
+            {
+                "openassetio-mediacreation:content.LocatableContent",
+                "openassetio-mediacreation:timeDomain.FrameRanged",
+            },
+            access.ResolveAccess.kRead,
+            a_context,
+        )
+
+        assert expeted_trait_data == actual_trait_data
+
+
+@pytest.fixture
+def a_fresh_simple_cpp_manager(the_args_for_manager_factory):
+    return ManagerFactory.defaultManagerForInterface(*the_args_for_manager_factory)
+
+
+@pytest.fixture(scope="module")
+def a_simple_cpp_manager(the_args_for_manager_factory):
+    return ManagerFactory.defaultManagerForInterface(*the_args_for_manager_factory)
+
+
+@pytest.fixture
+def a_context(a_simple_cpp_manager):
+    return a_simple_cpp_manager.createContext()
+
+
+@pytest.fixture(scope="module")
+def the_args_for_manager_factory():
+    class TestHostInterface(HostInterface):
+        def identifier(self):
+            return "org.openassetio.examples.SimpleCppManager.test"
+
+        def displayName(self):
+            return "Simple C++ Manager Test Host"
+
+    logger = ConsoleLogger()
+
+    host_interface = TestHostInterface()
+
+    impl_factory = CppPluginSystemManagerImplementationFactory(logger)
+
+    config_file_path = pathlib.Path(__file__).parent / "resources" / "openassetio_config.toml"
+
+    return (str(config_file_path), host_interface, impl_factory, logger)
diff --git a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
index 92f57365e..966f66afe 100644
--- a/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
+++ b/src/openassetio-python/package/openassetio/test/manager/apiComplianceSuite.py
@@ -257,18 +257,18 @@ def setUp(self):
         self.collectRequiredFixture("an_invalid_reference")
 
     def test_valid_reference_returns_true(self):
-        assert self._manager.isEntityReferenceString(self.a_valid_reference) is True
+        self.assertTrue(self._manager.isEntityReferenceString(self.a_valid_reference))
 
     def test_non_reference_returns_false(self):
         assert self.an_invalid_reference != ""
-        assert self._manager.isEntityReferenceString(self.an_invalid_reference) is False
+        self.assertFalse(self._manager.isEntityReferenceString(self.an_invalid_reference))
 
     def test_empty_string_returns_false(self):
-        assert self._manager.isEntityReferenceString("") is False
+        self.assertFalse(self._manager.isEntityReferenceString(""))
 
     def test_random_unicode_input_returns_false(self):
         unicode_reference = "🦆🦆🦑"
-        assert self._manager.isEntityReferenceString(unicode_reference) is False
+        self.assertFalse(self._manager.isEntityReferenceString(unicode_reference))
 
 
 class Test_entityExists(FixtureAugmentedTestCase):