Repository: OpenAssetIO/OpenAssetIO (language: python)

Issue:
Strategy for out-of-bounds index given to success/error callbacks
## What

Bounds check indices passed to success/error callbacks in convenience signatures. 


## Why

Avoid segfaults and undefined behavior by accessing out of bounds elements.

## Acceptance Criteria

If an out of bounds index is passed to a success/error callback, we should throw a nice exception with a human friendly `.what()`.
Conveniences for `defaultEntityReference`



Discussion / hints:
This is about conveniences mostly.

We should likely use .at() here rather than direct access. The manager is the one in control of the index here, it's a safety concern.


Fix patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b832d3c0e..0eace40c0 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -21,6 +21,12 @@ _This release remains source compatible for both hosts and managers._
   as keys in associative containers (e.g. `dict`/`std::unordered_map`).
   [#573](https://github.com/OpenAssetIO/OpenAssetIO/issues/573)
 
+- Added bounds checking of array access to convenience methods that wrap
+  batch-first callback-based signatures. This avoids some potential
+  segfaults if a manager plugin calls a callback passing an invalid index
+  argument.
+  [#1196](https://github.com/OpenAssetIO/OpenAssetIO/issues/1196)
+
 - Removed the requirement to define C++ destructors for classes
   inheriting from `LoggerInterface` and
   `ManagerImplementationFactoryInterface`. I.e. the destructors are no
diff --git a/src/openassetio-core/src/hostApi/ManagerConveniences.cpp b/src/openassetio-core/src/hostApi/ManagerConveniences.cpp
index 453ea30d3..86cc5d7ce 100644
--- a/src/openassetio-core/src/hostApi/ManagerConveniences.cpp
+++ b/src/openassetio-core/src/hostApi/ManagerConveniences.cpp
@@ -2,12 +2,14 @@
 // Copyright 2024 The Foundry Visionmongers Ltd
 #include <cstddef>
 #include <optional>
+#include <stdexcept>
 #include <utility>
 #include <variant>
 #include <vector>
 
-#include <openassetio/export.h>
+#include <fmt/format.h>
 
+#include <openassetio/export.h>
 #include <openassetio/Context.hpp>
 #include <openassetio/EntityReference.hpp>
 #include <openassetio/access.hpp>
@@ -25,6 +27,23 @@ namespace openassetio {
 inline namespace OPENASSETIO_CORE_ABI_VERSION {
 namespace hostApi {
 
+namespace {
+template <class Container>
+decltype(auto) safeGet(Container &container, const std::size_t idx) {
+  try {
+    return container.at(idx);
+  } catch (const std::out_of_range &) {
+    throw errors::InputValidationException(
+        fmt::format("Index '{}' out of bounds for batch size of {}", idx, container.size()));
+  }
+}
+
+template <class Container, class Element>
+void safeSet(Container &container, const std::size_t idx, Element &&element) {
+  safeGet(container, idx) = std::forward<Element>(element);
+}
+}  // namespace
+
 // The definitions below are the "convenience" method signatures -
 // alternate, often friendlier signatures wrapping the core batch-first
 // callback-based member functions found in `Manager.cpp`
@@ -92,12 +111,12 @@ std::vector<std::optional<EntityReference>> Manager::defaultEntityReference(
   defaultEntityReference(
       traitSets, defaultEntityAccess, context,
       [&results](std::size_t index, std::optional<EntityReference> entityReference) {
-        results[index] = std::move(entityReference);
+        safeSet(results, index, std::move(entityReference));
       },
       [&traitSets, defaultEntityAccess](std::size_t index, errors::BatchElementError error) {
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(defaultEntityAccess), std::nullopt,
-            traitSets[index]);
+            safeGet(traitSets, index));
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -115,10 +134,10 @@ Manager::defaultEntityReference(
   defaultEntityReference(
       traitSets, defaultEntityAccess, context,
       [&results](std::size_t index, std::optional<EntityReference> entityReference) {
-        results[index] = std::move(entityReference);
+        safeSet(results, index, std::move(entityReference));
       },
       [&results](std::size_t index, errors::BatchElementError error) {
-        results[index] = std::move(error);
+        safeSet(results, index, std::move(error));
       });
 
   return results;
@@ -170,11 +189,11 @@ std::vector<Manager::BoolAsUint> Manager::entityExists(
   entityExists(
       entityReferences, context,
       [&results](std::size_t index, bool exists) {
-        results[index] = static_cast<BoolAsUint>(exists);
+        safeSet(results, index, static_cast<BoolAsUint>(exists));
       },
       [&entityReferences](std::size_t index, errors::BatchElementError error) {
         auto msg = errors::createBatchElementExceptionMessage(
-            error, index, std::nullopt, entityReferences[index], std::nullopt);
+            error, index, std::nullopt, safeGet(entityReferences, index), std::nullopt);
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -189,11 +208,10 @@ std::vector<std::variant<errors::BatchElementError, bool>> Manager::entityExists
   results.resize(entityReferences.size());
   entityExists(
       entityReferences, context,
-      [&results](std::size_t index, bool exists) { results[index] = exists; },
+      [&results](std::size_t index, bool exists) { safeSet(results, index, exists); },
       [&results](std::size_t index, errors::BatchElementError error) {
-        results[index] = std::move(error);
+        safeSet(results, index, std::move(error));
       });
-
   return results;
 }
 
@@ -251,13 +269,13 @@ std::vector<trait::TraitSet> Manager::entityTraits(
   entityTraits(
       entityReferences, entityTraitsAccess, context,
       [&results](std::size_t index, trait::TraitSet traitSet) {
-        results[index] = std::move(traitSet);
+        safeSet(results, index, std::move(traitSet));
       },
       [&entityReferences, entityTraitsAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(entityTraitsAccess),
-            entityReferences[index], std::nullopt);
+            safeGet(entityReferences, index), std::nullopt);
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -274,12 +292,11 @@ std::vector<std::variant<errors::BatchElementError, trait::TraitSet>> Manager::e
   entityTraits(
       entityReferences, entityTraitsAccess, context,
       [&results](std::size_t index, trait::TraitSet traitSet) {
-        results[index] = std::move(traitSet);
+        safeSet(results, index, std::move(traitSet));
       },
       [&results](std::size_t index, errors::BatchElementError error) {
-        results[index] = std::move(error);
+        safeSet(results, index, std::move(error));
       });
-
   return results;
 }
 
@@ -337,13 +354,13 @@ std::vector<trait::TraitsDataPtr> Manager::resolve(
   resolve(
       entityReferences, traitSet, resolveAccess, context,
       [&resolveResult](std::size_t index, trait::TraitsDataPtr data) {
-        resolveResult[index] = std::move(data);
+        safeSet(resolveResult, index, std::move(data));
       },
       [&entityReferences, resolveAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(resolveAccess),
-            entityReferences[index], std::nullopt);
+            safeGet(entityReferences, index), std::nullopt);
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -360,10 +377,10 @@ std::vector<std::variant<errors::BatchElementError, trait::TraitsDataPtr>> Manag
   resolve(
       entityReferences, traitSet, resolveAccess, context,
       [&resolveResult](std::size_t index, trait::TraitsDataPtr data) {
-        resolveResult[index] = std::move(data);
+        safeSet(resolveResult, index, std::move(data));
       },
       [&resolveResult](std::size_t index, errors::BatchElementError error) {
-        resolveResult[index] = std::move(error);
+        safeSet(resolveResult, index, std::move(error));
       });
 
   return resolveResult;
@@ -420,13 +437,13 @@ EntityReferences Manager::preflight(
   preflight(
       entityReferences, traitsHints, publishingAccess, context,
       [&results](std::size_t index, EntityReference preflightedRef) {
-        results[index] = std::move(preflightedRef);
+        safeSet(results, index, std::move(preflightedRef));
       },
       [&entityReferences, publishingAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(publishingAccess),
-            entityReferences[index], std::nullopt);
+            safeGet(entityReferences, index), std::nullopt);
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -442,10 +459,10 @@ std::vector<std::variant<errors::BatchElementError, EntityReference>> Manager::p
   preflight(
       entityReferences, traitsHints, publishingAccess, context,
       [&results](std::size_t index, EntityReference entityReference) {
-        results[index] = std::move(entityReference);
+        safeSet(results, index, std::move(entityReference));
       },
       [&results](std::size_t index, errors::BatchElementError error) {
-        results[index] = std::move(error);
+        safeSet(results, index, std::move(error));
       });
 
   return results;
@@ -505,13 +522,13 @@ std::vector<EntityReference> Manager::register_(
   register_(
       entityReferences, entityTraitsDatas, publishingAccess, context,
       [&result](std::size_t index, EntityReference registeredRef) {
-        result[index] = std::move(registeredRef);
+        safeSet(result, index, std::move(registeredRef));
       },
       [&entityReferences, publishingAccess](std::size_t index, errors::BatchElementError error) {
         // Implemented as if FAILFAST is true.
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(publishingAccess),
-            entityReferences[index], std::nullopt);
+            safeGet(entityReferences, index), std::nullopt);
         throw errors::BatchElementException(index, std::move(error), msg);
       });
 
@@ -528,10 +545,10 @@ std::vector<std::variant<errors::BatchElementError, EntityReference>> Manager::r
   register_(
       entityReferences, entityTraitsDatas, publishingAccess, context,
       [&result](std::size_t index, EntityReference registeredRef) {
-        result[index] = std::move(registeredRef);
+        safeSet(result, index, std::move(registeredRef));
       },
       [&result](std::size_t index, errors::BatchElementError error) {
-        result[index] = std::move(error);
+        safeSet(result, index, std::move(error));
       });
 
   return result;
@@ -596,12 +613,13 @@ std::vector<EntityReferencePagerPtr> Manager::getWithRelationship(
   getWithRelationship(
       entityReferences, relationshipTraitsData, pageSize, relationsAccess, context,
       [&result](std::size_t index, EntityReferencePagerPtr pager) {
-        result[index] = std::move(pager);
+        safeSet(result, index, std::move(pager));
       },
-      [&entityReferences, relationsAccess](std::size_t index, errors::BatchElementError error) {
+      [&entityReferences, &relationshipTraitsData, relationsAccess](
+          std::size_t index, errors::BatchElementError error) {
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(relationsAccess),
-            entityReferences[index], std::nullopt);
+            safeGet(entityReferences, index), relationshipTraitsData->traitSet());
         throw errors::BatchElementException(index, std::move(error), msg);
       },
       resultTraitSet);
@@ -622,13 +640,12 @@ Manager::getWithRelationship(
   getWithRelationship(
       entityReferences, relationshipTraitsData, pageSize, relationsAccess, context,
       [&result](std::size_t index, EntityReferencePagerPtr pager) {
-        result[index] = std::move(pager);
+        safeSet(result, index, std::move(pager));
       },
       [&result](std::size_t index, errors::BatchElementError error) {
-        result[index] = std::move(error);
+        safeSet(result, index, std::move(error));
       },
       resultTraitSet);
-
   return result;
 }
 
@@ -647,12 +664,13 @@ std::vector<EntityReferencePagerPtr> Manager::getWithRelationships(
   getWithRelationships(
       entityReference, relationshipTraitsDatas, pageSize, relationsAccess, context,
       [&result](std::size_t index, EntityReferencePagerPtr pager) {
-        result[index] = std::move(pager);
+        safeSet(result, index, std::move(pager));
       },
-      [&entityReference, relationsAccess](std::size_t index, errors::BatchElementError error) {
+      [&entityReference, &relationshipTraitsDatas, relationsAccess](
+          std::size_t index, errors::BatchElementError error) {
         auto msg = errors::createBatchElementExceptionMessage(
             error, index, static_cast<internal::access::Access>(relationsAccess), entityReference,
-            std::nullopt);
+            safeGet(relationshipTraitsDatas, index)->traitSet());
         throw errors::BatchElementException(index, std::move(error), msg);
       },
       resultTraitSet);
@@ -672,10 +690,10 @@ Manager::getWithRelationships(
   getWithRelationships(
       entityReference, relationshipTraitsDatas, pageSize, relationsAccess, context,
       [&result](std::size_t index, EntityReferencePagerPtr pager) {
-        result[index] = std::move(pager);
+        safeSet(result, index, std::move(pager));
       },
       [&result](std::size_t index, errors::BatchElementError error) {
-        result[index] = std::move(error);
+        safeSet(result, index, std::move(error));
       },
       resultTraitSet);
 


Test patch:
diff --git a/src/openassetio-python/tests/package/hostApi/test_manager.py b/src/openassetio-python/tests/package/hostApi/test_manager.py
index b0a1fd72c..e1ee851a3 100644
--- a/src/openassetio-python/tests/package/hostApi/test_manager.py
+++ b/src/openassetio-python/tests/package/hostApi/test_manager.py
@@ -370,6 +370,47 @@ def call_callbacks(*_args):
                     for actual, expected in zip(actual_results, expected_results):
                         assert actual is expected
 
+    def assert_batch_overload_index_out_of_bounds_raises(
+        self, method_specific_args_for_batch_of_two, one_success_result
+    ):
+        expected_error_msg = "Index '3' out of bounds for batch size of 2"
+
+        # Success callback case.
+
+        def call_success_callback(*_args):
+            self.invoke_success_cb(3, one_success_result)
+
+        self.mock_interface_method.side_effect = call_success_callback
+
+        for tag in (
+            [],
+            [Manager.BatchElementErrorPolicyTag.kException],
+            [Manager.BatchElementErrorPolicyTag.kVariant],
+        ):
+            with self.subtests.test(tag=tag, callback="success"):
+                with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+                    self.method(*method_specific_args_for_batch_of_two, self.a_context, *tag)
+
+        # Error callback case.
+
+        error_result = BatchElementError(
+            BatchElementError.ErrorCode.kInvalidEntityReference, "some string ✨"
+        )
+
+        def call_error_callback(*_args):
+            self.invoke_error_cb(3, error_result)
+
+        self.mock_interface_method.side_effect = call_error_callback
+
+        for tag in (
+            [],
+            [Manager.BatchElementErrorPolicyTag.kException],
+            [Manager.BatchElementErrorPolicyTag.kVariant],
+        ):
+            with self.subtests.test(tag=tag, callback="error"):
+                with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+                    self.method(*method_specific_args_for_batch_of_two, self.a_context, *tag)
+
     def assert_singular_variant_overload_error(
         self,
         method_specific_args,
@@ -1037,6 +1078,12 @@ def test_when_batch_overload_receives_output_out_of_order_then_results_reordered
             expected_results=[True, False],
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(self, two_refs):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(two_refs,),
+            one_success_result=True,
+        )
+
     def test_when_singular_variant_overload_errors_then_error_returned(self, a_ref):
         self.assert_singular_variant_overload_error(
             method_specific_args=(a_ref,),
@@ -1179,6 +1226,17 @@ def test_when_batch_overload_receives_output_out_of_order_then_results_reordered
             assert_result_identity=False,
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(
+        self, two_entity_trait_sets, a_ref
+    ):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(
+                two_entity_trait_sets,
+                access.DefaultEntityAccess.kRead,
+            ),
+            one_success_result=a_ref,
+        )
+
     def test_when_singular_variant_overload_errors_then_error_returned(self, an_entity_trait_set):
         self.assert_singular_variant_overload_error(
             method_specific_args=(an_entity_trait_set, access.DefaultEntityAccess.kRead),
@@ -1284,10 +1342,11 @@ def next(self, hostSession):
         pass
 
 
-# The getWithRelationship tests are more repetitive than most tests
-# in this suite. They are unable to make use of the test
-# automation that we have written for the conveniences signatures, as
-# the arguments for getWithRelationship are not in the standard format.
+# The getWithRelationship tests are more repetitive than most tests in
+# this suite. They are unable to make use of the test automation (see
+# BatchFirstMethodTest) that we have written for the conveniences
+# signatures, as the arguments for getWithRelationship are not in the
+# standard format.
 
 
 class Test_Manager_getWithRelationship_with_callback_signature:
@@ -1795,12 +1854,71 @@ def call_callbacks(*_args):
             assert exc_info.value.error == a_batch_element_error
             assert (
                 exc_info.value.message
-                == "unknown: some message [index=0] [access=read] [entity=asset://a]"
+                == "unknown: some message [index=0] [access=read] [entity=asset://a] [traits={}]"
             )
         else:
             variant_error = manager.getWithRelationship(**args)
             assert variant_error == [a_batch_element_error, a_batch_element_error_2]
 
+    @pytest.mark.parametrize(
+        "error_mode",
+        [
+            None,
+            Manager.BatchElementErrorPolicyTag.kException,
+            Manager.BatchElementErrorPolicyTag.kVariant,
+        ],
+    )
+    def test_when_receives_out_of_bounds_index_then_raises(
+        self,
+        manager,
+        two_refs,
+        mock_manager_interface,
+        an_empty_traitsdata,
+        an_entity_trait_set,
+        a_context,
+        mock_entity_reference_pager_interface,
+        a_batch_element_error,
+        invoke_getWithRelationship_success_cb,
+        invoke_getWithRelationship_error_cb,
+        error_mode,
+    ):
+
+        expected_error_msg = "Index '3' out of bounds for batch size of 2"
+        page_size = 3
+        method = mock_manager_interface.mock.getWithRelationship
+
+        args = {
+            "entityReferences": two_refs,
+            "relationshipTraitsData": an_empty_traitsdata,
+            "pageSize": page_size,
+            "relationsAccess": access.RelationsAccess.kRead,
+            "context": a_context,
+            "resultTraitSet": an_entity_trait_set,
+        }
+
+        if error_mode is not None:
+            args["errorPolicyTag"] = error_mode
+
+        # Success callback case.
+
+        def call_success_callback(*_args):
+            invoke_getWithRelationship_success_cb(3, mock_entity_reference_pager_interface)
+
+        method.side_effect = call_success_callback
+
+        with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+            manager.getWithRelationship(**args)
+
+        # Error callback case
+
+        def call_error_callback(*_args):
+            invoke_getWithRelationship_error_cb(3, a_batch_element_error)
+
+        method.side_effect = call_error_callback
+
+        with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+            manager.getWithRelationship(**args)
+
 
 class Test_Manager_getWithRelationships_with_callback_signature:
     def test_wraps_the_corresponding_method_of_the_held_interface(
@@ -2137,12 +2255,72 @@ def call_callbacks(*_args):
             assert exc_info.value.error == a_batch_element_error
             assert (
                 exc_info.value.message
-                == "unknown: some message [index=0] [access=read] [entity=asset://a]"
+                == "unknown: some message [index=0] [access=read] [entity=asset://a] [traits={}]"
             )
         else:
             variant_error = manager.getWithRelationships(**args)
             assert variant_error == [a_batch_element_error, a_batch_element_error_2]
 
+    @pytest.mark.parametrize(
+        "error_mode",
+        [
+            None,
+            Manager.BatchElementErrorPolicyTag.kException,
+            Manager.BatchElementErrorPolicyTag.kVariant,
+        ],
+    )
+    def test_when_receives_out_of_bounds_index_then_raises(
+        self,
+        manager,
+        a_ref,
+        mock_manager_interface,
+        an_empty_traitsdata,
+        an_entity_trait_set,
+        a_context,
+        mock_entity_reference_pager_interface,
+        a_batch_element_error,
+        invoke_getWithRelationships_success_cb,
+        invoke_getWithRelationships_error_cb,
+        error_mode,
+    ):
+
+        expected_error_msg = "Index '3' out of bounds for batch size of 2"
+        two_traitsdatas = [an_empty_traitsdata, an_empty_traitsdata]
+        page_size = 3
+        method = mock_manager_interface.mock.getWithRelationships
+
+        args = {
+            "entityReference": a_ref,
+            "relationshipTraitsDatas": two_traitsdatas,
+            "pageSize": page_size,
+            "relationsAccess": access.RelationsAccess.kRead,
+            "context": a_context,
+            "resultTraitSet": an_entity_trait_set,
+        }
+
+        if error_mode is not None:
+            args["errorPolicyTag"] = error_mode
+
+        # Success callback case.
+
+        def call_success_callback(*_args):
+            invoke_getWithRelationships_success_cb(3, mock_entity_reference_pager_interface)
+
+        method.side_effect = call_success_callback
+
+        with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+            manager.getWithRelationships(**args)
+
+        # Error callback case
+
+        def call_error_callback(*_args):
+            invoke_getWithRelationships_error_cb(3, a_batch_element_error)
+
+        method.side_effect = call_error_callback
+
+        with pytest.raises(InputValidationException, match=re.escape(expected_error_msg)):
+            manager.getWithRelationships(**args)
+
 
 class Test_Manager_BatchElementErrorPolicyTag:
     def test_unique(self):
@@ -2249,6 +2427,18 @@ def test_when_batch_overload_receives_output_out_of_order_then_results_reordered
             expected_results=two_entity_traitsdatas,
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(
+        self, two_refs, an_entity_trait_set, a_traitsdata
+    ):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(
+                two_refs,
+                an_entity_trait_set,
+                access.ResolveAccess.kRead,
+            ),
+            one_success_result=a_traitsdata,
+        )
+
     def test_when_singular_variant_overload_errors_then_error_returned(
         self, a_ref, an_entity_trait_set
     ):
@@ -2437,6 +2627,17 @@ def test_when_batch_overload_receives_output_out_of_order_then_results_reordered
             assert_result_identity=False,
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(
+        self, two_refs, an_entity_trait_set
+    ):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(
+                two_refs,
+                access.EntityTraitsAccess.kRead,
+            ),
+            one_success_result=an_entity_trait_set,
+        )
+
     def test_when_singular_variant_overload_errors_then_error_returned(self, a_ref):
         self.assert_singular_variant_overload_error(
             method_specific_args=(
@@ -2717,6 +2918,18 @@ def test_when_singular_variant_overload_errors_then_error_returned(self, a_ref,
             ),
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(
+        self, a_ref, two_refs, two_entity_traitsdatas
+    ):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(
+                two_refs,
+                two_entity_traitsdatas,
+                access.PublishingAccess.kWrite,
+            ),
+            one_success_result=a_ref,
+        )
+
     def test_when_batch_variant_overload_receives_mixed_output_then_mixed_results_returned(
         self, two_refs, two_entity_traitsdatas, a_ref
     ):
@@ -2920,6 +3133,18 @@ def test_when_batch_overload_receives_output_out_of_order_then_results_reordered
             assert_result_identity=False,
         )
 
+    def test_when_batch_overload_receives_out_of_bounds_index_then_raises(
+        self, a_ref, two_refs, two_entity_traitsdatas
+    ):
+        self.assert_batch_overload_index_out_of_bounds_raises(
+            method_specific_args_for_batch_of_two=(
+                two_refs,
+                two_entity_traitsdatas,
+                access.PublishingAccess.kWrite,
+            ),
+            one_success_result=a_ref,
+        )
+
     def test_when_singular_variant_overload_errors_then_error_returned(self, a_ref, a_traitsdata):
         self.assert_singular_variant_overload_error(
             method_specific_args=(