diff --git a/include/crm/common/xml_idref_internal.h b/include/crm/common/xml_idref_internal.h
index 58f1c1b9e3..29313f1353 100644
--- a/include/crm/common/xml_idref_internal.h
+++ b/include/crm/common/xml_idref_internal.h
@@ -1,26 +1,28 @@
 /*
  * Copyright 2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML_IDREF_INTERNAL__H
 #define PCMK__CRM_COMMON_XML_IDREF_INTERNAL__H
 
 #include <glib.h>           // gboolean, gpointer, GList, GHashTable
 #include <libxml/tree.h>    // xmlNode
 
 // An XML ID and references to it (used for tags and templates)
 typedef struct {
     char *id;       // XML ID of primary element
     GList *refs;    // XML IDs of elements that reference the primary element
 } pcmk__idref_t;
 
 void pcmk__add_idref(GHashTable *table, const char *id, const char *referrer);
 void pcmk__free_idref(gpointer data);
 xmlNode *pcmk__xe_resolve_idref(xmlNode *xml, xmlNode *search);
+GList *pcmk__xe_dereference_children(const xmlNode *xml_obj,
+                                     const char *set_name);
 
 #endif // PCMK__CRM_COMMON_XML_IDREF_INTERNAL__H
diff --git a/lib/common/xml_idref.c b/lib/common/xml_idref.c
index 2721b8bf7e..583018ceed 100644
--- a/lib/common/xml_idref.c
+++ b/lib/common/xml_idref.c
@@ -1,115 +1,147 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <stdio.h>              // NULL
 #include <stdlib.h>             // free()
 #include <glib.h>               // GList, GHashTable, etc.
 #include <libxml/tree.h>        // xmlNode
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>     // get_xpath_object(), PCMK_XA_ID_REF
 
 /*!
  * \internal
  * \brief Add an XML ID reference to a table
  *
  * \param[in,out] table      Table of ID references to add to
  * \param[in]     id         ID of primary element being referred to
  * \param[in]     referrer   ID of element referring to \p id
  *
  * \note This refers to an ID reference in general, not necessarily connected to
  *       an id-ref attribute.
  */
 void
 pcmk__add_idref(GHashTable *table, const char *id, const char *referrer)
 {
     pcmk__idref_t *idref = NULL;
 
     pcmk__assert((table != NULL) && (id != NULL) && (referrer != NULL));
 
     idref = g_hash_table_lookup(table, id);
     if (idref == NULL) {
         idref = pcmk__assert_alloc(1, sizeof(pcmk__idref_t));
         idref->id = pcmk__str_copy(id);
         g_hash_table_insert(table, pcmk__str_copy(id), idref);
     }
     for (GList *iter = idref->refs; iter != NULL; iter = iter->next) {
         if (pcmk__str_eq(referrer, (const char *) iter->data,
                          pcmk__str_none)) {
             return; // Already present
         }
     }
     idref->refs = g_list_append(idref->refs, pcmk__str_copy(referrer));
     crm_trace("Added ID %s referrer %s", id, referrer);
 }
 
 /*!
  * \internal
  * \brief Free a pcmk__idref_t
  *
  * \param[in,out] data  pcmk__idref_t to free
  */
 void
 pcmk__free_idref(gpointer data)
 {
     pcmk__idref_t *idref = data;
 
     if (idref != NULL) {
         free(idref->id);
         g_list_free_full(idref->refs, free);
         free(idref);
     }
 }
 
 /*!
  * \internal
  * \brief Get the XML element whose \c PCMK_XA_ID matches an \c PCMK_XA_ID_REF
  *
  * \param[in] xml     Element whose \c PCMK_XA_ID_REF attribute to check
  * \param[in] search  Node whose document to search for node with matching
  *                    \c PCMK_XA_ID (\c NULL to use \p xml)
  *
  * \return If \p xml has a \c PCMK_XA_ID_REF attribute, node in
  *         <tt>search</tt>'s document whose \c PCMK_XA_ID attribute matches;
  *         otherwise, \p xml
  */
 xmlNode *
 pcmk__xe_resolve_idref(xmlNode *xml, xmlNode *search)
 {
     char *xpath = NULL;
     const char *ref = NULL;
     xmlNode *result = NULL;
 
     if (xml == NULL) {
         return NULL;
     }
 
     ref = crm_element_value(xml, PCMK_XA_ID_REF);
     if (ref == NULL) {
         return xml;
     }
 
     if (search == NULL) {
         search = xml;
     }
 
     xpath = crm_strdup_printf("//%s[@" PCMK_XA_ID "='%s']", xml->name, ref);
     result = get_xpath_object(xpath, search, LOG_DEBUG);
     if (result == NULL) {
         // Not possible with schema validation enabled
         pcmk__config_err("Ignoring invalid %s configuration: "
                          PCMK_XA_ID_REF " '%s' does not reference "
                          "a valid object " QB_XS " xpath=%s",
                          xml->name, ref, xpath);
     }
     free(xpath);
     return result;
 }
+
+/*!
+ * \internal
+ * \brief Get list of resolved ID references for child elements of given element
+ *
+ * \param[in]     xml_obj       XML element containing blocks of nvpair elements
+ * \param[in]     set_name      If not NULL, only get blocks of this element
+ *
+ * \return List of XML blocks of name/value pairs
+ */
+GList *
+pcmk__xe_dereference_children(const xmlNode *xml_obj, const char *set_name)
+{
+    GList *unsorted = NULL;
+
+    if (xml_obj == NULL) {
+        return NULL;
+    }
+    for (xmlNode *attr_set = pcmk__xe_first_child(xml_obj, NULL, NULL, NULL);
+         attr_set != NULL; attr_set = pcmk__xe_next(attr_set, NULL)) {
+
+        if ((set_name == NULL) || pcmk__xe_is(attr_set, set_name)) {
+            xmlNode *expanded_attr_set = pcmk__xe_resolve_idref(attr_set, NULL);
+
+            if (expanded_attr_set == NULL) {
+                continue; // Not possible with schema validation enabled
+            }
+            unsorted = g_list_prepend(unsorted, expanded_attr_set);
+        }
+    }
+    return unsorted;
+}
diff --git a/lib/pengine/rules.c b/lib/pengine/rules.c
index a01d42f9c9..6a46c1c032 100644
--- a/lib/pengine/rules.c
+++ b/lib/pengine/rules.c
@@ -1,257 +1,225 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <glib.h>
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/pengine/rules.h>
 
 #include <crm/common/iso8601_internal.h>
 #include <crm/common/nvpair_internal.h>
 #include <crm/common/rules_internal.h>
 #include <crm/common/xml_internal.h>
 #include <crm/pengine/internal.h>
 #include <crm/pengine/rules_internal.h>
 
 #include <sys/types.h>
 #include <regex.h>
 
 CRM_TRACE_INIT_DATA(pe_rules);
 
 /*!
  * \internal
  * \brief Map pe_rule_eval_data_t to pcmk_rule_input_t
  *
  * \param[out] new  New data struct
  * \param[in]  old  Old data struct
  */
 static void
 map_rule_input(pcmk_rule_input_t *new, const pe_rule_eval_data_t *old)
 {
     if (old == NULL) {
         return;
     }
     new->now = old->now;
     new->node_attrs = old->node_hash;
     if (old->rsc_data != NULL) {
         new->rsc_standard = old->rsc_data->standard;
         new->rsc_provider = old->rsc_data->provider;
         new->rsc_agent = old->rsc_data->agent;
     }
     if (old->match_data != NULL) {
         new->rsc_params = old->match_data->params;
         new->rsc_meta = old->match_data->meta;
         if (old->match_data->re != NULL) {
             new->rsc_id = old->match_data->re->string;
             new->rsc_id_submatches = old->match_data->re->pmatch;
             new->rsc_id_nmatches = old->match_data->re->nregs;
         }
     }
     if (old->op_data != NULL) {
         new->op_name = old->op_data->op_name;
         new->op_interval_ms = old->op_data->interval;
     }
 }
 
 static void
 populate_hash(xmlNode *nvpair_list, GHashTable *hash, bool overwrite)
 {
     if (pcmk__xe_is(nvpair_list->children, PCMK__XE_ATTRIBUTES)) {
         nvpair_list = nvpair_list->children;
     }
 
     for (xmlNode *nvpair = pcmk__xe_first_child(nvpair_list, PCMK_XE_NVPAIR,
                                                 NULL, NULL);
          nvpair != NULL; nvpair = pcmk__xe_next(nvpair, PCMK_XE_NVPAIR)) {
 
         xmlNode *ref_nvpair = pcmk__xe_resolve_idref(nvpair, NULL);
         const char *name = NULL;
         const char *value = NULL;
         const char *old_value = NULL;
 
         if (ref_nvpair == NULL) {
             /* Not possible with schema validation enabled (error already
              * logged)
              */
             continue;
         }
 
         name = crm_element_value(ref_nvpair, PCMK_XA_NAME);
         value = crm_element_value(ref_nvpair, PCMK_XA_VALUE);
         if ((name == NULL) || (value == NULL)) {
             continue;
         }
 
         old_value = g_hash_table_lookup(hash, name);
 
         if (pcmk__str_eq(value, "#default", pcmk__str_casei)) {
             // @COMPAT Deprecated since 2.1.8
             pcmk__config_warn("Support for setting meta-attributes (such as "
                               "%s) to the explicit value '#default' is "
                               "deprecated and will be removed in a future "
                               "release", name);
             if (old_value != NULL) {
                 crm_trace("Letting %s default (removing explicit value \"%s\")",
                           name, value);
                 g_hash_table_remove(hash, name);
             }
 
         } else if (old_value == NULL) {
             crm_trace("Setting %s=\"%s\"", name, value);
             pcmk__insert_dup(hash, name, value);
 
         } else if (overwrite) {
             crm_trace("Setting %s=\"%s\" (overwriting old value \"%s\")",
                       name, value, old_value);
             pcmk__insert_dup(hash, name, value);
         }
     }
 }
 
 static void
 unpack_attr_set(gpointer data, gpointer user_data)
 {
     xmlNode *pair = data;
     pcmk__nvpair_unpack_t *unpack_data = user_data;
 
     xmlNode *rule_xml = pcmk__xe_first_child(pair, PCMK_XE_RULE, NULL, NULL);
 
     if ((rule_xml != NULL)
         && (pcmk_evaluate_rule(rule_xml, &(unpack_data->rule_input),
                                unpack_data->next_change) != pcmk_rc_ok)) {
         return;
     }
 
     crm_trace("Adding name/value pairs from %s %s overwrite",
               pcmk__xe_id(pair), (unpack_data->overwrite? "with" : "without"));
     populate_hash(pair, unpack_data->values, unpack_data->overwrite);
 }
 
-/*!
- * \internal
- * \brief Create a sorted list of nvpair blocks
- *
- * \param[in]     xml_obj       XML element containing blocks of nvpair elements
- * \param[in]     set_name      If not NULL, only get blocks of this element
- *
- * \return List of XML blocks of name/value pairs
- */
-static GList *
-make_pairs(const xmlNode *xml_obj, const char *set_name)
-{
-    GList *unsorted = NULL;
-
-    if (xml_obj == NULL) {
-        return NULL;
-    }
-    for (xmlNode *attr_set = pcmk__xe_first_child(xml_obj, NULL, NULL, NULL);
-         attr_set != NULL; attr_set = pcmk__xe_next(attr_set, NULL)) {
-
-        if ((set_name == NULL) || pcmk__xe_is(attr_set, set_name)) {
-            xmlNode *expanded_attr_set = pcmk__xe_resolve_idref(attr_set, NULL);
-
-            if (expanded_attr_set == NULL) {
-                continue; // Not possible with schema validation enabled
-            }
-            unsorted = g_list_prepend(unsorted, expanded_attr_set);
-        }
-    }
-    return unsorted;
-}
-
 /*!
  * \brief Extract nvpair blocks contained by an XML element into a hash table
  *
  * \param[in,out] top           Ignored
  * \param[in]     xml_obj       XML element containing blocks of nvpair elements
  * \param[in]     set_name      If not NULL, only use blocks of this element
  * \param[in]     rule_data     Matching parameters to use when unpacking
  * \param[out]    hash          Where to store extracted name/value pairs
  * \param[in]     always_first  If not NULL, process block with this ID first
  * \param[in]     overwrite     Whether to replace existing values with same
  *                              name (all internal callers pass \c FALSE)
  * \param[out]    next_change   If not NULL, set to when evaluation will change
  */
 void
 pe_eval_nvpairs(xmlNode *top, const xmlNode *xml_obj, const char *set_name,
                 const pe_rule_eval_data_t *rule_data, GHashTable *hash,
                 const char *always_first, gboolean overwrite,
                 crm_time_t *next_change)
 {
-    GList *pairs = make_pairs(xml_obj, set_name);
+    GList *pairs = pcmk__xe_dereference_children(xml_obj, set_name);
 
     if (pairs) {
         pcmk__nvpair_unpack_t data = {
             .values = hash,
             .first_id = always_first,
             .overwrite = overwrite,
             .next_change = next_change,
         };
 
         map_rule_input(&(data.rule_input), rule_data);
 
         pairs = g_list_sort_with_data(pairs, pcmk__cmp_nvpair_blocks, &data);
         g_list_foreach(pairs, unpack_attr_set, &data);
         g_list_free(pairs);
     }
 }
 
 /*!
  * \brief Extract nvpair blocks contained by an XML element into a hash table
  *
  * \param[in,out] top           Ignored
  * \param[in]     xml_obj       XML element containing blocks of nvpair elements
  * \param[in]     set_name      Element name to identify nvpair blocks
  * \param[in]     node_hash     Node attributes to use when evaluating rules
  * \param[out]    hash          Where to store extracted name/value pairs
  * \param[in]     always_first  If not NULL, process block with this ID first
  * \param[in]     overwrite     Whether to replace existing values with same
  *                              name (all internal callers pass \c FALSE)
  * \param[in]     now           Time to use when evaluating rules
  * \param[out]    next_change   If not NULL, set to when evaluation will change
  */
 void
 pe_unpack_nvpairs(xmlNode *top, const xmlNode *xml_obj, const char *set_name,
                   GHashTable *node_hash, GHashTable *hash,
                   const char *always_first, gboolean overwrite,
                   crm_time_t *now, crm_time_t *next_change)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = NULL,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     pe_eval_nvpairs(NULL, xml_obj, set_name, &rule_data, hash,
                     always_first, overwrite, next_change);
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <crm/pengine/rules_compat.h>
 
 gboolean
 test_rule(xmlNode * rule, GHashTable * node_hash, enum rsc_role_e role, crm_time_t * now)
 {
     pcmk_rule_input_t rule_input = {
         .node_attrs = node_hash,
         .now = now,
     };
 
     return pcmk_evaluate_rule(rule, &rule_input, NULL) == pcmk_rc_ok;
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API