diff --git a/lib/common/crmcommon_private.h b/lib/common/crmcommon_private.h
index 3229067cfd..7baabdf04b 100644
--- a/lib/common/crmcommon_private.h
+++ b/lib/common/crmcommon_private.h
@@ -1,449 +1,455 @@
 /*
  * Copyright 2018-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__COMMON_CRMCOMMON_PRIVATE__H
 #define PCMK__COMMON_CRMCOMMON_PRIVATE__H
 
 /* This header is for the sole use of libcrmcommon, so that functions can be
  * declared with G_GNUC_INTERNAL for efficiency.
  */
 
 #include <stdint.h>         // uint8_t, uint32_t
 #include <stdbool.h>        // bool
 #include <sys/types.h>      // size_t
 
 #include <glib.h>           // G_GNUC_INTERNAL, G_GNUC_PRINTF, gchar, etc.
 #include <libxml/tree.h>    // xmlNode, xmlAttr
 #include <qb/qbipcc.h>      // struct qb_ipc_response_header
 
 #include <crm/common/ipc.h>             // pcmk_ipc_api_t, crm_ipc_t, etc.
 #include <crm/common/iso8601.h>         // crm_time_t
 #include <crm/common/logging.h>         // LOG_NEVER
 #include <crm/common/mainloop.h>        // mainloop_io_t
 #include <crm/common/output_internal.h> // pcmk__output_t
 #include <crm/common/results.h>         // crm_exit_t
 #include <crm/common/rules.h>           // pcmk_rule_input_t
 #include <crm/common/xml.h>             // pcmkXmlStr
 #include <crm/common/xml_internal.h>    // enum xml_private_flags
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 // Decent chunk size for processing large amounts of data
 #define PCMK__BUFFER_SIZE 4096
 
 #if defined(PCMK__UNIT_TESTING)
 #undef G_GNUC_INTERNAL
 #define G_GNUC_INTERNAL
 #endif
 
 /* When deleting portions of an XML tree, we keep a record so we can know later
  * (e.g. when checking differences) that something was deleted.
  */
 typedef struct pcmk__deleted_xml_s {
     gchar *path;
     int position;
 } pcmk__deleted_xml_t;
 
 typedef struct xml_node_private_s {
         uint32_t check;
         uint32_t flags;
 } xml_node_private_t;
 
 typedef struct xml_doc_private_s {
         uint32_t check;
         uint32_t flags;
         char *user;
         GList *acls;
         GList *deleted_objs; // List of pcmk__deleted_xml_t
 } xml_doc_private_t;
 
 // XML private data magic numbers
 #define PCMK__XML_DOC_PRIVATE_MAGIC     0x81726354UL
 #define PCMK__XML_NODE_PRIVATE_MAGIC    0x54637281UL
 
 // XML entity references
 #define PCMK__XML_ENTITY_AMP    "&amp;"
 #define PCMK__XML_ENTITY_GT     "&gt;"
 #define PCMK__XML_ENTITY_LT     "&lt;"
 #define PCMK__XML_ENTITY_QUOT   "&quot;"
 
 #define pcmk__set_xml_flags(xml_priv, flags_to_set) do {                    \
         (xml_priv)->flags = pcmk__set_flags_as(__func__, __LINE__,          \
             LOG_NEVER, "XML", "XML node", (xml_priv)->flags,                \
             (flags_to_set), #flags_to_set);                                 \
     } while (0)
 
 #define pcmk__clear_xml_flags(xml_priv, flags_to_clear) do {                \
         (xml_priv)->flags = pcmk__clear_flags_as(__func__, __LINE__,        \
             LOG_NEVER, "XML", "XML node", (xml_priv)->flags,                \
             (flags_to_clear), #flags_to_clear);                             \
     } while (0)
 
+G_GNUC_INTERNAL
+void pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags);
+
 G_GNUC_INTERNAL
 xmlDoc *pcmk__xml_new_doc(void);
 
 G_GNUC_INTERNAL
 bool pcmk__tracking_xml_changes(xmlNode *xml, bool lazy);
 
 G_GNUC_INTERNAL
 void pcmk__xml_mark_created(xmlNode *xml);
 
 G_GNUC_INTERNAL
 int pcmk__xml_position(const xmlNode *xml,
                        enum xml_private_flags ignore_if_set);
 
 G_GNUC_INTERNAL
 xmlNode *pcmk__xml_match(const xmlNode *haystack, const xmlNode *needle,
                          bool exact);
 
 G_GNUC_INTERNAL
 xmlNode *pcmk__xc_match(const xmlNode *root, const xmlNode *search_comment,
                         bool exact);
 
 G_GNUC_INTERNAL
 void pcmk__xc_update(xmlNode *parent, xmlNode *target, xmlNode *update);
 
 G_GNUC_INTERNAL
 void pcmk__free_acls(GList *acls);
 
 G_GNUC_INTERNAL
 void pcmk__unpack_acl(xmlNode *source, xmlNode *target, const char *user);
 
 G_GNUC_INTERNAL
 bool pcmk__is_user_in_group(const char *user, const char *group);
 
 G_GNUC_INTERNAL
 void pcmk__apply_acl(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__apply_creation_acl(xmlNode *xml, bool check_top);
 
+G_GNUC_INTERNAL
+int pcmk__xa_remove(xmlAttr *attr);
+
 G_GNUC_INTERNAL
 void pcmk__mark_xml_attr_dirty(xmlAttr *a);
 
 G_GNUC_INTERNAL
 bool pcmk__xa_filterable(const char *name);
 
 G_GNUC_INTERNAL
 void pcmk__log_xmllib_err(void *ctx, const char *fmt, ...)
 G_GNUC_PRINTF(2, 3);
 
 G_GNUC_INTERNAL
 void pcmk__mark_xml_node_dirty(xmlNode *xml);
 
 G_GNUC_INTERNAL
 bool pcmk__marked_as_deleted(xmlAttrPtr a, void *user_data);
 
 G_GNUC_INTERNAL
 void pcmk__dump_xml_attr(const xmlAttr *attr, GString *buffer);
 
 G_GNUC_INTERNAL
 int pcmk__xe_set_score(xmlNode *target, const char *name, const char *value);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_is_name_start_char(const char *utf8, int *len);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_is_name_char(const char *utf8, int *len);
 
 /*
  * Date/times
  */
 
 // For use with pcmk__add_time_from_xml()
 enum pcmk__time_component {
     pcmk__time_unknown,
     pcmk__time_years,
     pcmk__time_months,
     pcmk__time_weeks,
     pcmk__time_days,
     pcmk__time_hours,
     pcmk__time_minutes,
     pcmk__time_seconds,
 };
 
 G_GNUC_INTERNAL
 const char *pcmk__time_component_attr(enum pcmk__time_component component);
 
 G_GNUC_INTERNAL
 int pcmk__add_time_from_xml(crm_time_t *t, enum pcmk__time_component component,
                             const xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__set_time_if_earlier(crm_time_t *target, const crm_time_t *source);
 
 
 /*
  * IPC
  */
 
 #define PCMK__IPC_VERSION 1
 
 #define PCMK__CONTROLD_API_MAJOR "1"
 #define PCMK__CONTROLD_API_MINOR "0"
 
 // IPC behavior that varies by daemon
 typedef struct pcmk__ipc_methods_s {
     /*!
      * \internal
      * \brief Allocate any private data needed by daemon IPC
      *
      * \param[in,out] api  IPC API connection
      *
      * \return Standard Pacemaker return code
      */
     int (*new_data)(pcmk_ipc_api_t *api);
 
     /*!
      * \internal
      * \brief Free any private data used by daemon IPC
      *
      * \param[in,out] api_data  Data allocated by new_data() method
      */
     void (*free_data)(void *api_data);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling after successful connection
      *
      * Some daemons require clients to register before sending any other
      * commands. The controller requires a CRM_OP_HELLO (with no reply), and
      * the CIB manager, executor, and fencer require a CRM_OP_REGISTER (with a
      * reply). Ideally this would be consistent across all daemons, but for now
      * this allows each to do its own authorization.
      *
      * \param[in,out] api  IPC API connection
      *
      * \return Standard Pacemaker return code
      */
     int (*post_connect)(pcmk_ipc_api_t *api);
 
     /*!
      * \internal
      * \brief Check whether an IPC request results in a reply
      *
      * \param[in,out] api      IPC API connection
      * \param[in]     request  IPC request XML
      *
      * \return true if request would result in an IPC reply, false otherwise
      */
     bool (*reply_expected)(pcmk_ipc_api_t *api, const xmlNode *request);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling of an IPC message
      *
      * \param[in,out] api  IPC API connection
      * \param[in,out] msg  Message read from IPC connection
      *
      * \return true if more IPC reply messages should be expected
      */
     bool (*dispatch)(pcmk_ipc_api_t *api, xmlNode *msg);
 
     /*!
      * \internal
      * \brief Perform daemon-specific handling of an IPC disconnect
      *
      * \param[in,out] api  IPC API connection
      */
     void (*post_disconnect)(pcmk_ipc_api_t *api);
 } pcmk__ipc_methods_t;
 
 // Implementation of pcmk_ipc_api_t
 struct pcmk_ipc_api_s {
     enum pcmk_ipc_server server;          // Daemon this IPC API instance is for
     enum pcmk_ipc_dispatch dispatch_type; // How replies should be dispatched
     size_t ipc_size_max;                  // maximum IPC buffer size
     crm_ipc_t *ipc;                       // IPC connection
     mainloop_io_t *mainloop_io;     // If using mainloop, I/O source for IPC
     bool free_on_disconnect;        // Whether disconnect should free object
     pcmk_ipc_callback_t cb;         // Caller-registered callback (if any)
     void *user_data;                // Caller-registered data (if any)
     void *api_data;                 // For daemon-specific use
     pcmk__ipc_methods_t *cmds;      // Behavior that varies by daemon
 };
 
 typedef struct pcmk__ipc_header_s {
     struct qb_ipc_response_header qb;
     uint32_t size_uncompressed;
     uint32_t size_compressed;
     uint32_t flags;
     uint8_t version;
 } pcmk__ipc_header_t;
 
 G_GNUC_INTERNAL
 int pcmk__send_ipc_request(pcmk_ipc_api_t *api, const xmlNode *request);
 
 G_GNUC_INTERNAL
 void pcmk__call_ipc_callback(pcmk_ipc_api_t *api,
                              enum pcmk_ipc_event event_type,
                              crm_exit_t status, void *event_data);
 
 G_GNUC_INTERNAL
 unsigned int pcmk__ipc_buffer_size(unsigned int max);
 
 G_GNUC_INTERNAL
 bool pcmk__valid_ipc_header(const pcmk__ipc_header_t *header);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__attrd_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__controld_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__pacemakerd_api_methods(void);
 
 G_GNUC_INTERNAL
 pcmk__ipc_methods_t *pcmk__schedulerd_api_methods(void);
 
 
 /*
  * Logging
  */
 
 //! XML is newly created
 #define PCMK__XML_PREFIX_CREATED "++"
 
 //! XML has been deleted
 #define PCMK__XML_PREFIX_DELETED "--"
 
 //! XML has been modified
 #define PCMK__XML_PREFIX_MODIFIED "+ "
 
 //! XML has been moved
 #define PCMK__XML_PREFIX_MOVED "+~"
 
 /*
  * Output
  */
 G_GNUC_INTERNAL
 int pcmk__bare_output_new(pcmk__output_t **out, const char *fmt_name,
                           const char *filename, char **argv);
 
 G_GNUC_INTERNAL
 void pcmk__register_option_messages(pcmk__output_t *out);
 
 G_GNUC_INTERNAL
 void pcmk__register_patchset_messages(pcmk__output_t *out);
 
 G_GNUC_INTERNAL
 bool pcmk__output_text_get_fancy(pcmk__output_t *out);
 
 /*
  * Rules
  */
 
 // How node attribute values may be compared in rules
 enum pcmk__comparison {
     pcmk__comparison_unknown,
     pcmk__comparison_defined,
     pcmk__comparison_undefined,
     pcmk__comparison_eq,
     pcmk__comparison_ne,
     pcmk__comparison_lt,
     pcmk__comparison_lte,
     pcmk__comparison_gt,
     pcmk__comparison_gte,
 };
 
 // How node attribute values may be parsed in rules
 enum pcmk__type {
     pcmk__type_unknown,
     pcmk__type_string,
     pcmk__type_integer,
     pcmk__type_number,
     pcmk__type_version,
 };
 
 // Where to obtain reference value for a node attribute comparison
 enum pcmk__reference_source {
     pcmk__source_unknown,
     pcmk__source_literal,
     pcmk__source_instance_attrs,
     pcmk__source_meta_attrs,
 };
 
 G_GNUC_INTERNAL
 enum pcmk__comparison pcmk__parse_comparison(const char *op);
 
 G_GNUC_INTERNAL
 enum pcmk__type pcmk__parse_type(const char *type, enum pcmk__comparison op,
                                  const char *value1, const char *value2);
 
 G_GNUC_INTERNAL
 enum pcmk__reference_source pcmk__parse_source(const char *source);
 
 G_GNUC_INTERNAL
 int pcmk__cmp_by_type(const char *value1, const char *value2,
                       enum pcmk__type type);
 
 G_GNUC_INTERNAL
 int pcmk__unpack_duration(const xmlNode *duration, const crm_time_t *start,
                           crm_time_t **end);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_date_spec(const xmlNode *date_spec, const crm_time_t *now);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_attr_expression(const xmlNode *expression,
                                    const pcmk_rule_input_t *rule_input);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_rsc_expression(const xmlNode *expr,
                                   const pcmk_rule_input_t *rule_input);
 
 G_GNUC_INTERNAL
 int pcmk__evaluate_op_expression(const xmlNode *expr,
                                  const pcmk_rule_input_t *rule_input);
 
 
 /*
  * Utils
  */
 #define PCMK__PW_BUFFER_LEN 500
 
 
 /*
  * Schemas
  */
 typedef struct {
     unsigned char v[2];
 } pcmk__schema_version_t;
 
 enum pcmk__schema_validator {
     pcmk__schema_validator_none,
     pcmk__schema_validator_rng
 };
 
 typedef struct {
     int schema_index;
     char *name;
 
     /*!
      * List of XSLT stylesheets for upgrading from this schema version to the
      * next one. Sorted by the order in which they should be applied to the CIB.
      */
     GList *transforms;
 
     void *cache;
     enum pcmk__schema_validator validator;
     pcmk__schema_version_t version;
 } pcmk__schema_t;
 
 G_GNUC_INTERNAL
 GList *pcmk__find_x_0_schema(void);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif  // PCMK__COMMON_CRMCOMMON_PRIVATE__H
diff --git a/lib/common/xml.c b/lib/common/xml.c
index f8efc14ad4..34c8d3849f 100644
--- a/lib/common/xml.c
+++ b/lib/common/xml.c
@@ -1,2650 +1,2616 @@
 /*
  * 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 <stdarg.h>
 #include <stdint.h>                     // uint32_t
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/stat.h>                   // stat(), S_ISREG, etc.
 #include <sys/types.h>
 
 #include <glib.h>                       // gboolean, GString
 #include <libxml/parser.h>
 #include <libxml/tree.h>
 #include <libxml/xmlstring.h>           // xmlGetUTF8Char()
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>    // PCMK__XML_LOG_BASE, etc.
 #include "crmcommon_private.h"
 
 //! libxml2 supports only XML version 1.0, at least as of libxml2-2.12.5
 #define XML_VERSION ((pcmkXmlStr) "1.0")
 
 /*!
  * \internal
  * \brief Apply a function to each XML node in a tree (pre-order, depth-first)
  *
  * \param[in,out] xml        XML tree to traverse
  * \param[in,out] fn         Function to call for each node (returns \c true to
  *                           continue traversing the tree or \c false to stop)
  * \param[in,out] user_data  Argument to \p fn
  *
  * \return \c false if any \p fn call returned \c false, or \c true otherwise
  *
  * \note This function is recursive.
  */
 bool
 pcmk__xml_tree_foreach(xmlNode *xml, bool (*fn)(xmlNode *, void *),
                        void *user_data)
 {
     if (!fn(xml, user_data)) {
         return false;
     }
 
     for (xml = pcmk__xml_first_child(xml); xml != NULL;
          xml = pcmk__xml_next(xml)) {
 
         if (!pcmk__xml_tree_foreach(xml, fn, user_data)) {
             return false;
         }
     }
     return true;
 }
 
 bool
 pcmk__tracking_xml_changes(xmlNode *xml, bool lazy)
 {
     if(xml == NULL || xml->doc == NULL || xml->doc->_private == NULL) {
         return FALSE;
     } else if (!pcmk_is_set(((xml_doc_private_t *)xml->doc->_private)->flags,
                             pcmk__xf_tracking)) {
         return FALSE;
     } else if (lazy && !pcmk_is_set(((xml_doc_private_t *)xml->doc->_private)->flags,
                                     pcmk__xf_lazy)) {
         return FALSE;
     }
     return TRUE;
 }
 
-static inline void
-set_parent_flag(xmlNode *xml, long flag) 
+void
+pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags)
 {
-    for(; xml; xml = xml->parent) {
+    for (; xml != NULL; xml = xml->parent) {
         xml_node_private_t *nodepriv = xml->_private;
 
-        if (nodepriv == NULL) {
-            /* During calls to xmlDocCopyNode(), _private will be unset for parent nodes */
-        } else {
-            pcmk__set_xml_flags(nodepriv, flag);
+        if (nodepriv != NULL) {
+            /* During calls to xmlDocCopyNode(), _private will be unset for
+             * parent nodes
+             */
+            pcmk__set_xml_flags(nodepriv, flags);
         }
     }
 }
 
 void
 pcmk__set_xml_doc_flag(xmlNode *xml, enum xml_private_flags flag)
 {
     if(xml && xml->doc && xml->doc->_private){
         /* During calls to xmlDocCopyNode(), xml->doc may be unset */
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         pcmk__set_xml_flags(docpriv, flag);
     }
 }
 
 // Mark document, element, and all element's parents as changed
 void
 pcmk__mark_xml_node_dirty(xmlNode *xml)
 {
     pcmk__set_xml_doc_flag(xml, pcmk__xf_dirty);
-    set_parent_flag(xml, pcmk__xf_dirty);
+    pcmk__xml_set_parent_flags(xml, pcmk__xf_dirty);
 }
 
 /*!
  * \internal
  * \brief Clear flags on an XML node
  *
  * \param[in,out] xml        XML node whose flags to reset
  * \param[in,out] user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 reset_xml_node_flags(xmlNode *xml, void *user_data)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (nodepriv != NULL) {
         nodepriv->flags = pcmk__xf_none;
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Set the \c pcmk__xf_dirty and \c pcmk__xf_created flags on an XML node
  *
  * \param[in,out] xml        Node whose flags to set
  * \param[in]     user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 mark_xml_dirty_created(xmlNode *xml, void *user_data)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (nodepriv != NULL) {
         pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Mark an XML tree as dirty and created, and mark its parents dirty
  *
  * Also mark the document dirty.
  *
  * \param[in,out] xml  Tree to mark as dirty and created
  */
 void
 pcmk__xml_mark_created(xmlNode *xml)
 {
     CRM_ASSERT(xml != NULL);
 
     if (!pcmk__tracking_xml_changes(xml, false)) {
         // Tracking is disabled for entire document
         return;
     }
 
     // Mark all parents and document dirty
     pcmk__mark_xml_node_dirty(xml);
 
     pcmk__xml_tree_foreach(xml, mark_xml_dirty_created, NULL);
 }
 
 // Free an XML object previously marked as deleted
 static void
 free_deleted_object(void *data)
 {
     if(data) {
         pcmk__deleted_xml_t *deleted_obj = data;
 
         g_free(deleted_obj->path);
         free(deleted_obj);
     }
 }
 
 // Free and NULL user, ACLs, and deleted objects in an XML node's private data
 static void
 reset_xml_private_data(xml_doc_private_t *docpriv)
 {
     if (docpriv != NULL) {
         CRM_ASSERT(docpriv->check == PCMK__XML_DOC_PRIVATE_MAGIC);
 
         free(docpriv->user);
         docpriv->user = NULL;
 
         if (docpriv->acls != NULL) {
             pcmk__free_acls(docpriv->acls);
             docpriv->acls = NULL;
         }
 
         if(docpriv->deleted_objs) {
             g_list_free_full(docpriv->deleted_objs, free_deleted_object);
             docpriv->deleted_objs = NULL;
         }
     }
 }
 
 // Free all private data associated with an XML node
 static void
 free_private_data(xmlNode *node)
 {
     /* Note:
     
     This function frees private data assosciated with an XML node,
     unless the function is being called as a result of internal
     XSLT cleanup.
     
     That could happen through, for example, the following chain of
     function calls:
     
        xsltApplyStylesheetInternal
     -> xsltFreeTransformContext
     -> xsltFreeRVTs
     -> xmlFreeDoc
 
     And in that case, the node would fulfill three conditions:
     
     1. It would be a standalone document (i.e. it wouldn't be 
        part of a document)
     2. It would have a space-prefixed name (for reference, please
        see xsltInternals.h: XSLT_MARK_RES_TREE_FRAG)
     3. It would carry its own payload in the _private field.
     
     We do not free data in this circumstance to avoid a failed
     assertion on the PCMK__XML_*_PRIVATE_MAGIC later.
     
     */
     if (node->name == NULL || node->name[0] != ' ') {
         if (node->_private) {
             if (node->type == XML_DOCUMENT_NODE) {
                 reset_xml_private_data(node->_private);
             } else {
                 CRM_ASSERT(((xml_node_private_t *) node->_private)->check
                                == PCMK__XML_NODE_PRIVATE_MAGIC);
                 /* nothing dynamically allocated nested */
             }
             free(node->_private);
             node->_private = NULL;
         }
     }
 }
 
 // Allocate and initialize private data for an XML node
 static void
 new_private_data(xmlNode *node)
 {
     switch (node->type) {
         case XML_DOCUMENT_NODE: {
             xml_doc_private_t *docpriv =
                 pcmk__assert_alloc(1, sizeof(xml_doc_private_t));
 
             docpriv->check = PCMK__XML_DOC_PRIVATE_MAGIC;
             /* Flags will be reset if necessary when tracking is enabled */
             pcmk__set_xml_flags(docpriv, pcmk__xf_dirty|pcmk__xf_created);
             node->_private = docpriv;
             break;
         }
         case XML_ELEMENT_NODE:
         case XML_ATTRIBUTE_NODE:
         case XML_COMMENT_NODE: {
             xml_node_private_t *nodepriv =
                 pcmk__assert_alloc(1, sizeof(xml_node_private_t));
 
             nodepriv->check = PCMK__XML_NODE_PRIVATE_MAGIC;
             /* Flags will be reset if necessary when tracking is enabled */
             pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
             node->_private = nodepriv;
             if (pcmk__tracking_xml_changes(node, FALSE)) {
                 /* XML_ELEMENT_NODE doesn't get picked up here, node->doc is
                  * not hooked up at the point we are called
                  */
                 pcmk__mark_xml_node_dirty(node);
             }
             break;
         }
         case XML_TEXT_NODE:
         case XML_DTD_NODE:
         case XML_CDATA_SECTION_NODE:
             break;
         default:
             /* Ignore */
             crm_trace("Ignoring %p %d", node, node->type);
             CRM_LOG_ASSERT(node->type == XML_ELEMENT_NODE);
             break;
     }
 }
 
 void
 xml_track_changes(xmlNode * xml, const char *user, xmlNode *acl_source, bool enforce_acls) 
 {
     xml_accept_changes(xml);
     crm_trace("Tracking changes%s to %p", enforce_acls?" with ACLs":"", xml);
     pcmk__set_xml_doc_flag(xml, pcmk__xf_tracking);
     if(enforce_acls) {
         if(acl_source == NULL) {
             acl_source = xml;
         }
         pcmk__set_xml_doc_flag(xml, pcmk__xf_acl_enabled);
         pcmk__unpack_acl(acl_source, xml, user);
         pcmk__apply_acl(xml);
     }
 }
 
 bool xml_tracking_changes(xmlNode * xml)
 {
     return (xml != NULL) && (xml->doc != NULL) && (xml->doc->_private != NULL)
            && pcmk_is_set(((xml_doc_private_t *)(xml->doc->_private))->flags,
                           pcmk__xf_tracking);
 }
 
 bool xml_document_dirty(xmlNode *xml) 
 {
     return (xml != NULL) && (xml->doc != NULL) && (xml->doc->_private != NULL)
            && pcmk_is_set(((xml_doc_private_t *)(xml->doc->_private))->flags,
                           pcmk__xf_dirty);
 }
 
 /*!
  * \internal
  * \brief Return ordinal position of an XML node among its siblings
  *
  * \param[in] xml            XML node to check
  * \param[in] ignore_if_set  Don't count siblings with this flag set
  *
  * \return Ordinal position of \p xml (starting with 0)
  */
 int
 pcmk__xml_position(const xmlNode *xml, enum xml_private_flags ignore_if_set)
 {
     int position = 0;
 
     for (const xmlNode *cIter = xml; cIter->prev; cIter = cIter->prev) {
         xml_node_private_t *nodepriv = ((xmlNode*)cIter->prev)->_private;
 
         if (!pcmk_is_set(nodepriv->flags, ignore_if_set)) {
             position++;
         }
     }
 
     return position;
 }
 
 /*!
  * \internal
  * \brief Remove all attributes marked as deleted from an XML node
  *
  * \param[in,out] xml        XML node whose deleted attributes to remove
  * \param[in,out] user_data  Ignored
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 accept_attr_deletions(xmlNode *xml, void *user_data)
 {
     reset_xml_node_flags(xml, NULL);
     pcmk__xe_remove_matching_attrs(xml, pcmk__marked_as_deleted, NULL);
     return true;
 }
 
 /*!
  * \internal
  * \brief Find first child XML node matching another given XML node
  *
  * \param[in] haystack  XML whose children should be checked
  * \param[in] needle    XML to match (comment content or element name and ID)
  * \param[in] exact     If true and needle is a comment, position must match
  */
 xmlNode *
 pcmk__xml_match(const xmlNode *haystack, const xmlNode *needle, bool exact)
 {
     CRM_CHECK(needle != NULL, return NULL);
 
     if (needle->type == XML_COMMENT_NODE) {
         return pcmk__xc_match(haystack, needle, exact);
 
     } else {
         const char *id = pcmk__xe_id(needle);
         const char *attr = (id == NULL)? NULL : PCMK_XA_ID;
 
         return pcmk__xe_first_child(haystack, (const char *) needle->name, attr,
                                     id);
     }
 }
 
 void
 xml_accept_changes(xmlNode * xml)
 {
     xmlNode *top = NULL;
     xml_doc_private_t *docpriv = NULL;
 
     if(xml == NULL) {
         return;
     }
 
     crm_trace("Accepting changes to %p", xml);
     docpriv = xml->doc->_private;
     top = xmlDocGetRootElement(xml->doc);
 
     reset_xml_private_data(xml->doc->_private);
 
     if (!pcmk_is_set(docpriv->flags, pcmk__xf_dirty)) {
         docpriv->flags = pcmk__xf_none;
         return;
     }
 
     docpriv->flags = pcmk__xf_none;
     pcmk__xml_tree_foreach(top, accept_attr_deletions, NULL);
 }
 
 /*!
  * \internal
  * \brief Find first XML child element matching given criteria
  *
  * \param[in] parent     XML element to search (can be \c NULL)
  * \param[in] node_name  If not \c NULL, only match children of this type
  * \param[in] attr_n     If not \c NULL, only match children with an attribute
  *                       of this name.
  * \param[in] attr_v     If \p attr_n and this are not NULL, only match children
  *                       with an attribute named \p attr_n and this value
  *
  * \return Matching XML child element, or \c NULL if none found
  */
 xmlNode *
 pcmk__xe_first_child(const xmlNode *parent, const char *node_name,
                      const char *attr_n, const char *attr_v)
 {
     xmlNode *child = NULL;
     const char *parent_name = "<null>";
 
     CRM_CHECK((attr_v == NULL) || (attr_n != NULL), return NULL);
 
     if (parent != NULL) {
         child = parent->children;
         while ((child != NULL) && (child->type != XML_ELEMENT_NODE)) {
             child = child->next;
         }
 
         parent_name = (const char *) parent->name;
     }
 
     for (; child != NULL; child = pcmk__xe_next(child)) {
         const char *value = NULL;
 
         if ((node_name != NULL) && !pcmk__xe_is(child, node_name)) {
             // Node name mismatch
             continue;
         }
         if (attr_n == NULL) {
             // No attribute match needed
             return child;
         }
 
         value = crm_element_value(child, attr_n);
 
         if ((attr_v == NULL) && (value != NULL)) {
             // attr_v == NULL: Attribute attr_n must be set (to any value)
             return child;
         }
         if ((attr_v != NULL) && (pcmk__str_eq(value, attr_v, pcmk__str_none))) {
             // attr_v != NULL: Attribute attr_n must be set to value attr_v
             return child;
         }
     }
 
     if (node_name == NULL) {
         node_name = "(any)";    // For logging
     }
     if (attr_n != NULL) {
         crm_trace("XML child node <%s %s=%s> not found in %s",
                   node_name, attr_n, attr_v, parent_name);
     } else {
         crm_trace("XML child node <%s> not found in %s",
                   node_name, parent_name);
     }
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Set an XML attribute, expanding \c ++ and \c += where appropriate
  *
  * If \p target already has an attribute named \p name set to an integer value
  * and \p value is an addition assignment expression on \p name, then expand
  * \p value to an integer and set attribute \p name to the expanded value in
  * \p target.
  *
  * Otherwise, set attribute \p name on \p target using the literal \p value.
  *
  * The original attribute value in \p target and the number in an assignment
  * expression in \p value are parsed and added as scores (that is, their values
  * are capped at \c INFINITY and \c -INFINITY). For more details, refer to
  * \c char2score().
  *
  * For example, suppose \p target has an attribute named \c "X" with value
  * \c "5", and that \p name is \c "X".
  * * If \p value is \c "X++", the new value of \c "X" in \p target is \c "6".
  * * If \p value is \c "X+=3", the new value of \c "X" in \p target is \c "8".
  * * If \p value is \c "val", the new value of \c "X" in \p target is \c "val".
  * * If \p value is \c "Y++", the new value of \c "X" in \p target is \c "Y++".
  *
  * \param[in,out] target  XML node whose attribute to set
  * \param[in]     name    Name of the attribute to set
  * \param[in]     value   New value of attribute to set
  *
  * \return Standard Pacemaker return code (specifically, \c EINVAL on invalid
  *         argument, or \c pcmk_rc_ok otherwise)
  */
 int
 pcmk__xe_set_score(xmlNode *target, const char *name, const char *value)
 {
     const char *old_value = NULL;
 
     CRM_CHECK((target != NULL) && (name != NULL), return EINVAL);
 
     if (value == NULL) {
         return pcmk_rc_ok;
     }
 
     old_value = crm_element_value(target, name);
 
     // If no previous value, skip to default case and set the value unexpanded.
     if (old_value != NULL) {
         const char *n = name;
         const char *v = value;
 
         // Stop at first character that differs between name and value
         for (; (*n == *v) && (*n != '\0'); n++, v++);
 
         // If value begins with name followed by a "++" or "+="
         if ((*n == '\0')
             && (*v++ == '+')
             && ((*v == '+') || (*v == '='))) {
 
             // If we're expanding ourselves, no previous value was set; use 0
             int old_value_i = (old_value != value)? char2score(old_value) : 0;
 
             /* value="X++": new value of X is old_value + 1
              * value="X+=Y": new value of X is old_value + Y (for some number Y)
              */
             int add = (*v == '+')? 1 : char2score(++v);
 
             crm_xml_add_int(target, name, pcmk__add_scores(old_value_i, add));
             return pcmk_rc_ok;
         }
     }
 
     // Default case: set the attribute unexpanded (with value treated literally)
     if (old_value != value) {
         crm_xml_add(target, name, value);
     }
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Copy XML attributes from a source element to a target element
  *
  * This is similar to \c xmlCopyPropList() except that attributes are marked
  * as dirty for change tracking purposes.
  *
  * \param[in,out] target  XML element to receive copied attributes from \p src
  * \param[in]     src     XML element whose attributes to copy to \p target
  * \param[in]     flags   Group of <tt>enum pcmk__xa_flags</tt>
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__xe_copy_attrs(xmlNode *target, const xmlNode *src, uint32_t flags)
 {
     CRM_CHECK((src != NULL) && (target != NULL), return EINVAL);
 
     for (xmlAttr *attr = pcmk__xe_first_attr(src); attr != NULL;
          attr = attr->next) {
 
         const char *name = (const char *) attr->name;
         const char *value = pcmk__xml_attr_value(attr);
 
         if (pcmk_is_set(flags, pcmk__xaf_no_overwrite)
             && (crm_element_value(target, name) != NULL)) {
             continue;
         }
 
         if (pcmk_is_set(flags, pcmk__xaf_score_update)) {
             pcmk__xe_set_score(target, name, value);
         } else {
             crm_xml_add(target, name, value);
         }
     }
 
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Compare two XML attributes by name
  *
  * \param[in] a  First XML attribute to compare
  * \param[in] b  Second XML attribute to compare
  *
  * \retval  negative \c a->name is \c NULL or comes before \c b->name
  *                   lexicographically
  * \retval  0        \c a->name and \c b->name are equal
  * \retval  positive \c b->name is \c NULL or comes before \c a->name
  *                   lexicographically
  */
 static gint
 compare_xml_attr(gconstpointer a, gconstpointer b)
 {
     const xmlAttr *attr_a = a;
     const xmlAttr *attr_b = b;
 
     return pcmk__strcmp((const char *) attr_a->name,
                         (const char *) attr_b->name, pcmk__str_none);
 }
 
 /*!
  * \internal
  * \brief Sort an XML element's attributes by name
  *
  * This does not consider ACLs and does not mark the attributes as deleted or
  * dirty. Upon return, all attributes still exist and are set to the same values
  * as before the call. The only thing that may change is the order of the
  * attribute list.
  *
  * \param[in,out] xml  XML element whose attributes to sort
  */
 void
 pcmk__xe_sort_attrs(xmlNode *xml)
 {
     GSList *attr_list = NULL;
 
     for (xmlAttr *iter = pcmk__xe_first_attr(xml); iter != NULL;
          iter = iter->next) {
         attr_list = g_slist_prepend(attr_list, iter);
     }
     attr_list = g_slist_sort(attr_list, compare_xml_attr);
 
     for (GSList *iter = attr_list; iter != NULL; iter = iter->next) {
         xmlNode *attr = iter->data;
 
         xmlUnlinkNode(attr);
         xmlAddChild(xml, attr);
     }
     g_slist_free(attr_list);
 }
 
-/*!
- * \internal
- * \brief Remove an XML attribute from an element
- *
- * \param[in,out] element  XML element that owns \p attr
- * \param[in,out] attr     XML attribute to remove from \p element
- *
- * \return Standard Pacemaker return code (\c EPERM if ACLs prevent removal of
- *         attributes from \p element, or \c pcmk_rc_ok otherwise)
- */
-static int
-remove_xe_attr(xmlNode *element, xmlAttr *attr)
-{
-    if (attr == NULL) {
-        return pcmk_rc_ok;
-    }
-
-    if (!pcmk__check_acl(element, NULL, pcmk__xf_acl_write)) {
-        // ACLs apply to element, not to particular attributes
-        crm_trace("ACLs prevent removal of attributes from %s element",
-                  (const char *) element->name);
-        return EPERM;
-    }
-
-    if (pcmk__tracking_xml_changes(element, false)) {
-        // Leave in place (marked for removal) until after diff is calculated
-        set_parent_flag(element, pcmk__xf_dirty);
-        pcmk__set_xml_flags((xml_node_private_t *) attr->_private,
-                            pcmk__xf_deleted);
-    } else {
-        xmlRemoveProp(attr);
-    }
-    return pcmk_rc_ok;
-}
-
 /*!
  * \internal
  * \brief Remove a named attribute from an XML element
  *
  * \param[in,out] element  XML element to remove an attribute from
  * \param[in]     name     Name of attribute to remove
  */
 void
 pcmk__xe_remove_attr(xmlNode *element, const char *name)
 {
     if (name != NULL) {
-        remove_xe_attr(element, xmlHasProp(element, (pcmkXmlStr) name));
+        pcmk__xa_remove(xmlHasProp(element, (pcmkXmlStr) name));
     }
 }
 
 /*!
  * \internal
  * \brief Remove a named attribute from an XML element
  *
  * This is a wrapper for \c pcmk__xe_remove_attr() for use with
  * \c pcmk__xml_tree_foreach().
  *
  * \param[in,out] xml        XML element to remove an attribute from
  * \param[in]     user_data  Name of attribute to remove
  *
  * \return \c true (to continue traversing the tree)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 bool
 pcmk__xe_remove_attr_cb(xmlNode *xml, void *user_data)
 {
     const char *name = user_data;
 
     pcmk__xe_remove_attr(xml, name);
     return true;
 }
 
 /*!
  * \internal
  * \brief Remove an XML element's attributes that match some criteria
  *
  * \param[in,out] element    XML element to modify
  * \param[in]     match      If not NULL, only remove attributes for which
  *                           this function returns true
  * \param[in,out] user_data  Data to pass to \p match
  */
 void
 pcmk__xe_remove_matching_attrs(xmlNode *element,
                                bool (*match)(xmlAttrPtr, void *),
                                void *user_data)
 {
     xmlAttrPtr next = NULL;
 
     for (xmlAttrPtr a = pcmk__xe_first_attr(element); a != NULL; a = next) {
         next = a->next; // Grab now because attribute might get removed
         if ((match == NULL) || match(a, user_data)) {
-            if (remove_xe_attr(element, a) != pcmk_rc_ok) {
+            if (pcmk__xa_remove(a) != pcmk_rc_ok) {
                 return;
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Create a new XML element under a given parent
  *
  * \param[in,out] parent  XML element that will be the new element's parent
  *                        (\c NULL to create a new XML document with the new
  *                        node as root)
  * \param[in]     name    Name of new element
  *
  * \return Newly created XML element (guaranteed not to be \c NULL)
  */
 xmlNode *
 pcmk__xe_create(xmlNode *parent, const char *name)
 {
     xmlNode *node = NULL;
 
     CRM_ASSERT(!pcmk__str_empty(name));
 
     if (parent == NULL) {
         xmlDoc *doc = pcmk__xml_new_doc();
 
         node = xmlNewDocRawNode(doc, NULL, (pcmkXmlStr) name, NULL);
         pcmk__mem_assert(node);
 
         xmlDocSetRootElement(doc, node);
 
     } else {
         node = xmlNewChild(parent, NULL, (pcmkXmlStr) name, NULL);
         pcmk__mem_assert(node);
     }
 
     pcmk__xml_mark_created(node);
     return node;
 }
 
 /*!
  * \internal
  * \brief Create a new XML document
  *
  * \return Newly allocated XML document (guaranteed not to be \c NULL)
  *
  * \note The caller is responsible for freeing the return value using
  *       \c pcmk__xml_free_doc().
  */
 xmlDoc *
 pcmk__xml_new_doc(void)
 {
     /* @TODO Allocate document private data here when we drop
      * new_private_data()/free_private_data()
      */
     xmlDoc *doc = xmlNewDoc(XML_VERSION);
 
     pcmk__mem_assert(doc);
     return doc;
 }
 
 /*!
  * \internal
  * \brief Free a new XML document
  *
  * \param[in,out] doc  XML document to free
  */
 void
 pcmk__xml_free_doc(xmlDoc *doc)
 {
     /* @TODO Free document private data here when we drop
      * new_private_data()/free_private_data()
      */
     if (doc != NULL) {
         xmlFreeDoc(doc);
     }
 }
 
 /*!
  * \internal
  * \brief Set a formatted string as an XML node's content
  *
  * \param[in,out] node    Node whose content to set
  * \param[in]     format  <tt>printf(3)</tt>-style format string
  * \param[in]     ...     Arguments for \p format
  *
  * \note This function escapes special characters. \c xmlNodeSetContent() does
  *       not.
  */
 G_GNUC_PRINTF(2, 3)
 void
 pcmk__xe_set_content(xmlNode *node, const char *format, ...)
 {
     if (node != NULL) {
         const char *content = NULL;
         char *buf = NULL;
 
         if (strchr(format, '%') == NULL) {
             // Nothing to format
             content = format;
 
         } else {
             va_list ap;
 
             va_start(ap, format);
 
             if (pcmk__str_eq(format, "%s", pcmk__str_none)) {
                 // No need to make a copy
                 content = va_arg(ap, const char *);
 
             } else {
                 CRM_ASSERT(vasprintf(&buf, format, ap) >= 0);
                 content = buf;
             }
             va_end(ap);
         }
 
         xmlNodeSetContent(node, (pcmkXmlStr) content);
         free(buf);
     }
 }
 
 /*!
  * \internal
  * \brief Check whether the first character of a string is an XML NameStartChar
  *
  * See https://www.w3.org/TR/xml/#NT-NameStartChar.
  *
  * This is almost identical to libxml2's \c xmlIsDocNameStartChar(), but they
  * don't expose it as part of the public API.
  *
  * \param[in]  utf8  UTF-8 encoded string
  * \param[out] len   If not \c NULL, where to store size in bytes of first
  *                   character in \p utf8
  *
  * \return \c true if \p utf8 begins with a valid XML NameStartChar, or \c false
  *         otherwise
  */
 bool
 pcmk__xml_is_name_start_char(const char *utf8, int *len)
 {
     int c = 0;
     int local_len = 0;
 
     if (len == NULL) {
         len = &local_len;
     }
 
     /* xmlGetUTF8Char() abuses the len argument. At call time, it must be set to
      * "the minimum number of bytes present in the sequence... to assure the
      * next character is completely contained within the sequence." It's similar
      * to the "n" in the strn*() functions. However, this doesn't make any sense
      * for null-terminated strings, and there's no value that indicates "keep
      * going until '\0'." So we set it to 4, the max number of bytes in a UTF-8
      * character.
      *
      * At return, it's set to the actual number of bytes in the char, or 0 on
      * error.
      */
     *len = 4;
 
     // Note: xmlGetUTF8Char() assumes a 32-bit int
     c = xmlGetUTF8Char((pcmkXmlStr) utf8, len);
     if (c < 0) {
         GString *buf = g_string_sized_new(32);
 
         for (int i = 0; (i < 4) && (utf8[i] != '\0'); i++) {
             g_string_append_printf(buf, " 0x%.2X", utf8[i]);
         }
         crm_info("Invalid UTF-8 character (bytes:%s)",
                  (pcmk__str_empty(buf->str)? " <none>" : buf->str));
         g_string_free(buf, TRUE);
         return false;
     }
 
     return (c == '_')
            || (c == ':')
            || ((c >= 'a') && (c <= 'z'))
            || ((c >= 'A') && (c <= 'Z'))
            || ((c >= 0xC0) && (c <= 0xD6))
            || ((c >= 0xD8) && (c <= 0xF6))
            || ((c >= 0xF8) && (c <= 0x2FF))
            || ((c >= 0x370) && (c <= 0x37D))
            || ((c >= 0x37F) && (c <= 0x1FFF))
            || ((c >= 0x200C) && (c <= 0x200D))
            || ((c >= 0x2070) && (c <= 0x218F))
            || ((c >= 0x2C00) && (c <= 0x2FEF))
            || ((c >= 0x3001) && (c <= 0xD7FF))
            || ((c >= 0xF900) && (c <= 0xFDCF))
            || ((c >= 0xFDF0) && (c <= 0xFFFD))
            || ((c >= 0x10000) && (c <= 0xEFFFF));
 }
 
 /*!
  * \internal
  * \brief Check whether the first character of a string is an XML NameChar
  *
  * See https://www.w3.org/TR/xml/#NT-NameChar.
  *
  * This is almost identical to libxml2's \c xmlIsDocNameChar(), but they don't
  * expose it as part of the public API.
  *
  * \param[in]  utf8  UTF-8 encoded string
  * \param[out] len   If not \c NULL, where to store size in bytes of first
  *                   character in \p utf8
  *
  * \return \c true if \p utf8 begins with a valid XML NameChar, or \c false
  *         otherwise
  */
 bool
 pcmk__xml_is_name_char(const char *utf8, int *len)
 {
     int c = 0;
     int local_len = 0;
 
     if (len == NULL) {
         len = &local_len;
     }
 
     // See comment regarding len in pcmk__xml_is_name_start_char()
     *len = 4;
 
     // Note: xmlGetUTF8Char() assumes a 32-bit int
     c = xmlGetUTF8Char((pcmkXmlStr) utf8, len);
     if (c < 0) {
         GString *buf = g_string_sized_new(32);
 
         for (int i = 0; (i < 4) && (utf8[i] != '\0'); i++) {
             g_string_append_printf(buf, " 0x%.2X", utf8[i]);
         }
         crm_info("Invalid UTF-8 character (bytes:%s)",
                  (pcmk__str_empty(buf->str)? " <none>" : buf->str));
         g_string_free(buf, TRUE);
         return false;
     }
 
     return ((c >= 'a') && (c <= 'z'))
            || ((c >= 'A') && (c <= 'Z'))
            || ((c >= '0') && (c <= '9'))
            || (c == '_')
            || (c == ':')
            || (c == '-')
            || (c == '.')
            || (c == 0xB7)
            || ((c >= 0xC0) && (c <= 0xD6))
            || ((c >= 0xD8) && (c <= 0xF6))
            || ((c >= 0xF8) && (c <= 0x2FF))
            || ((c >= 0x300) && (c <= 0x36F))
            || ((c >= 0x370) && (c <= 0x37D))
            || ((c >= 0x37F) && (c <= 0x1FFF))
            || ((c >= 0x200C) && (c <= 0x200D))
            || ((c >= 0x203F) && (c <= 0x2040))
            || ((c >= 0x2070) && (c <= 0x218F))
            || ((c >= 0x2C00) && (c <= 0x2FEF))
            || ((c >= 0x3001) && (c <= 0xD7FF))
            || ((c >= 0xF900) && (c <= 0xFDCF))
            || ((c >= 0xFDF0) && (c <= 0xFFFD))
            || ((c >= 0x10000) && (c <= 0xEFFFF));
 }
 
 /*!
  * \internal
  * \brief Sanitize a string so it is usable as an XML ID
  *
  * An ID must match the Name production as defined here:
  * https://www.w3.org/TR/xml/#NT-Name.
  *
  * Convert an invalid start character to \c '_'. Convert an invalid character
  * after the start character to \c '.'.
  *
  * \param[in,out] id  String to sanitize
  */
 void
 pcmk__xml_sanitize_id(char *id)
 {
     bool valid = true;
     int len = 0;
 
     // If id is empty or NULL, there's no way to make it a valid XML ID
     CRM_ASSERT(!pcmk__str_empty(id));
 
     /* @TODO Suppose there are two strings and each has an invalid ID character
      * in the same position. The strings are otherwise identical. Both strings
      * will be sanitized to the same valid ID, which is incorrect.
      *
      * The caller is responsible for ensuring the sanitized ID does not already
      * exist in a given XML document before using it, if uniqueness is desired.
      */
     valid = pcmk__xml_is_name_start_char(id, &len);
     CRM_CHECK(len > 0, return); // UTF-8 encoding error
     if (!valid) {
         *id = '_';
         for (int i = 1; i < len; i++) {
             id[i] = '.';
         }
     }
 
     for (id += len; *id != '\0'; id += len) {
         valid = pcmk__xml_is_name_char(id, &len);
         CRM_CHECK(len > 0, return); // UTF-8 encoding error
         if (!valid) {
             for (int i = 0; i < len; i++) {
                 id[i] = '.';
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Set a formatted string as an XML element's ID
  *
  * If the formatted string would not be a valid ID, it's first sanitized by
  * \c pcmk__xml_sanitize_id().
  *
  * \param[in,out] node    Node whose ID to set
  * \param[in]     format  <tt>printf(3)</tt>-style format string
  * \param[in]     ...     Arguments for \p format
  */
 G_GNUC_PRINTF(2, 3)
 void
 pcmk__xe_set_id(xmlNode *node, const char *format, ...)
 {
     char *id = NULL;
     va_list ap;
 
     CRM_ASSERT(!pcmk__str_empty(format));
 
     if (node == NULL) {
         return;
     }
 
     va_start(ap, format);
     CRM_ASSERT(vasprintf(&id, format, ap) >= 0);
     va_end(ap);
 
     if (!xmlValidateNameValue((pcmkXmlStr) id)) {
         pcmk__xml_sanitize_id(id);
     }
     crm_xml_add(node, PCMK_XA_ID, id);
     free(id);
 }
 
 /*!
  * \internal
  * \brief Free an XML tree without ACL checks or change tracking
  *
  * \param[in,out] xml  XML node to free
  */
 static void
 free_xml_node(xmlNode *xml)
 {
     /* @TODO Free tree private data here when we drop
      * new_private_data()/free_private_data()
      */
     xmlUnlinkNode(xml);
     xmlFreeNode(xml);
 }
 
 /*!
  * \internal
  * \brief Free an XML tree if ACLs allow; track deletion if tracking is enabled
  *
  * If \p node is the root of its document, free the entire document.
  *
  * \param[in,out] node      XML node to free
  * \param[in]     position  Position of \p node among its siblings for change
  *                          tracking (negative to calculate automatically if
  *                          needed)
  */
 static void
 free_xml_with_position(xmlNode *node, int position)
 {
     xmlDoc *doc = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     if (node == NULL) {
         return;
     }
     doc = node->doc;
     nodepriv = node->_private;
 
     if ((doc != NULL) && (xmlDocGetRootElement(doc) == node)) {
         /* @TODO Should we check ACLs first? Otherwise it seems like we could
          * free the root element without write permission.
          */
         pcmk__xml_free_doc(doc);
         return;
     }
 
     if (!pcmk__check_acl(node, NULL, pcmk__xf_acl_write)) {
         GString *xpath = NULL;
 
         pcmk__if_tracing({}, return);
         xpath = pcmk__element_xpath(node);
         qb_log_from_external_source(__func__, __FILE__,
                                     "Cannot remove %s %x", LOG_TRACE,
                                     __LINE__, 0, xpath->str, nodepriv->flags);
         g_string_free(xpath, TRUE);
         return;
     }
 
     if ((doc != NULL) && pcmk__tracking_xml_changes(node, false)
         && !pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
 
         xml_doc_private_t *docpriv = doc->_private;
         GString *xpath = pcmk__element_xpath(node);
 
         if (xpath != NULL) {
             pcmk__deleted_xml_t *deleted_obj = NULL;
 
             crm_trace("Deleting %s %p from %p", xpath->str, node, doc);
 
             deleted_obj = pcmk__assert_alloc(1, sizeof(pcmk__deleted_xml_t));
             deleted_obj->path = g_string_free(xpath, FALSE);
             deleted_obj->position = -1;
 
             // Record the position only for XML comments for now
             if (node->type == XML_COMMENT_NODE) {
                 if (position >= 0) {
                     deleted_obj->position = position;
 
                 } else {
                     deleted_obj->position = pcmk__xml_position(node,
                                                                pcmk__xf_skip);
                 }
             }
 
             docpriv->deleted_objs = g_list_append(docpriv->deleted_objs,
                                                   deleted_obj);
             pcmk__set_xml_doc_flag(node, pcmk__xf_dirty);
         }
     }
     free_xml_node(node);
 }
 
 /*!
  * \internal
  * \brief Free an XML tree if ACLs allow; track deletion if tracking is enabled
  *
  * If \p xml is the root of its document, free the entire document.
  *
  * \param[in,out] xml  XML node to free
  */
 void
 pcmk__xml_free(xmlNode *xml)
 {
     free_xml_with_position(xml, -1);
 }
 
 /*!
  * \internal
  * \brief Make a deep copy of an XML node under a given parent
  *
  * \param[in,out] parent  XML element that will be the copy's parent (\c NULL
  *                        to create a new XML document with the copy as root)
  * \param[in]     src     XML node to copy
  *
  * \return Deep copy of \p src, or \c NULL if \p src is \c NULL
  */
 xmlNode *
 pcmk__xml_copy(xmlNode *parent, xmlNode *src)
 {
     xmlNode *copy = NULL;
 
     if (src == NULL) {
         return NULL;
     }
 
     if (parent == NULL) {
         xmlDoc *doc = NULL;
 
         // The copy will be the root element of a new document
         CRM_ASSERT(src->type == XML_ELEMENT_NODE);
 
         doc = pcmk__xml_new_doc();
         copy = xmlDocCopyNode(src, doc, 1);
         pcmk__mem_assert(copy);
 
         xmlDocSetRootElement(doc, copy);
 
     } else {
         copy = xmlDocCopyNode(src, parent->doc, 1);
         pcmk__mem_assert(copy);
 
         xmlAddChild(parent, copy);
     }
 
     pcmk__xml_mark_created(copy);
     return copy;
 }
 
 /*!
  * \internal
  * \brief Remove XML text nodes from specified XML and all its children
  *
  * \param[in,out] xml  XML to strip text from
  */
 void
 pcmk__strip_xml_text(xmlNode *xml)
 {
     xmlNode *iter = xml->children;
 
     while (iter) {
         xmlNode *next = iter->next;
 
         switch (iter->type) {
             case XML_TEXT_NODE:
                 free_xml_node(iter);
                 break;
 
             case XML_ELEMENT_NODE:
                 /* Search it */
                 pcmk__strip_xml_text(iter);
                 break;
 
             default:
                 /* Leave it */
                 break;
         }
 
         iter = next;
     }
 }
 
 /*!
  * \internal
  * \brief Add a "last written" attribute to an XML element, set to current time
  *
  * \param[in,out] xe  XML element to add attribute to
  *
  * \return Value that was set, or NULL on error
  */
 const char *
 pcmk__xe_add_last_written(xmlNode *xe)
 {
     char *now_s = pcmk__epoch2str(NULL, 0);
     const char *result = NULL;
 
     result = crm_xml_add(xe, PCMK_XA_CIB_LAST_WRITTEN,
                          pcmk__s(now_s, "Could not determine current time"));
     free(now_s);
     return result;
 }
 
 /*!
  * \internal
  * \brief Check whether a string has XML special characters that must be escaped
  *
  * See \c pcmk__xml_escape() and \c pcmk__xml_escape_type for more details.
  *
  * \param[in] text  String to check
  * \param[in] type  Type of escaping
  *
  * \return \c true if \p text has special characters that need to be escaped, or
  *         \c false otherwise
  */
 bool
 pcmk__xml_needs_escape(const char *text, enum pcmk__xml_escape_type type)
 {
     if (text == NULL) {
         return false;
     }
 
     while (*text != '\0') {
         switch (type) {
             case pcmk__xml_escape_text:
                 switch (*text) {
                     case '<':
                     case '>':
                     case '&':
                         return true;
                     case '\n':
                     case '\t':
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             return true;
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr:
                 switch (*text) {
                     case '<':
                     case '>':
                     case '&':
                     case '"':
                         return true;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             return true;
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr_pretty:
                 switch (*text) {
                     case '\n':
                     case '\r':
                     case '\t':
                     case '"':
                         return true;
                     default:
                         break;
                 }
                 break;
 
             default:    // Invalid enum value
                 CRM_ASSERT(false);
                 break;
         }
 
         text = g_utf8_next_char(text);
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Replace special characters with their XML escape sequences
  *
  * \param[in] text  Text to escape
  * \param[in] type  Type of escaping
  *
  * \return Newly allocated string equivalent to \p text but with special
  *         characters replaced with XML escape sequences (or \c NULL if \p text
  *         is \c NULL). If \p text is not \c NULL, the return value is
  *         guaranteed not to be \c NULL.
  *
  * \note There are libxml functions that purport to do this:
  *       \c xmlEncodeEntitiesReentrant() and \c xmlEncodeSpecialChars().
  *       However, their escaping is incomplete. See:
  *       https://discourse.gnome.org/t/intended-use-of-xmlencodeentitiesreentrant-vs-xmlencodespecialchars/19252
  * \note The caller is responsible for freeing the return value using
  *       \c g_free().
  */
 gchar *
 pcmk__xml_escape(const char *text, enum pcmk__xml_escape_type type)
 {
     GString *copy = NULL;
 
     if (text == NULL) {
         return NULL;
     }
     copy = g_string_sized_new(strlen(text));
 
     while (*text != '\0') {
         // Don't escape any non-ASCII characters
         if ((*text & 0x80) != 0) {
             size_t bytes = g_utf8_next_char(text) - text;
 
             g_string_append_len(copy, text, bytes);
             text += bytes;
             continue;
         }
 
         switch (type) {
             case pcmk__xml_escape_text:
                 switch (*text) {
                     case '<':
                         g_string_append(copy, PCMK__XML_ENTITY_LT);
                         break;
                     case '>':
                         g_string_append(copy, PCMK__XML_ENTITY_GT);
                         break;
                     case '&':
                         g_string_append(copy, PCMK__XML_ENTITY_AMP);
                         break;
                     case '\n':
                     case '\t':
                         g_string_append_c(copy, *text);
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             g_string_append_printf(copy, "&#x%.2X;", *text);
                         } else {
                             g_string_append_c(copy, *text);
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr:
                 switch (*text) {
                     case '<':
                         g_string_append(copy, PCMK__XML_ENTITY_LT);
                         break;
                     case '>':
                         g_string_append(copy, PCMK__XML_ENTITY_GT);
                         break;
                     case '&':
                         g_string_append(copy, PCMK__XML_ENTITY_AMP);
                         break;
                     case '"':
                         g_string_append(copy, PCMK__XML_ENTITY_QUOT);
                         break;
                     default:
                         if (g_ascii_iscntrl(*text)) {
                             g_string_append_printf(copy, "&#x%.2X;", *text);
                         } else {
                             g_string_append_c(copy, *text);
                         }
                         break;
                 }
                 break;
 
             case pcmk__xml_escape_attr_pretty:
                 switch (*text) {
                     case '"':
                         g_string_append(copy, "\\\"");
                         break;
                     case '\n':
                         g_string_append(copy, "\\n");
                         break;
                     case '\r':
                         g_string_append(copy, "\\r");
                         break;
                     case '\t':
                         g_string_append(copy, "\\t");
                         break;
                     default:
                         g_string_append_c(copy, *text);
                         break;
                 }
                 break;
 
             default:    // Invalid enum value
                 CRM_ASSERT(false);
                 break;
         }
 
         text = g_utf8_next_char(text);
     }
     return g_string_free(copy, FALSE);
 }
 
 /*!
  * \internal
  * \brief Set a flag on all attributes of an XML element
  *
  * \param[in,out] xml   XML node to set flags on
  * \param[in]     flag  XML private flag to set
  */
 static void
 set_attrs_flag(xmlNode *xml, enum xml_private_flags flag)
 {
     for (xmlAttr *attr = pcmk__xe_first_attr(xml); attr; attr = attr->next) {
         pcmk__set_xml_flags((xml_node_private_t *) (attr->_private), flag);
     }
 }
 
 /*!
  * \internal
  * \brief Add an XML attribute to a node, marked as deleted
  *
  * When calculating XML changes, we need to know when an attribute has been
  * deleted. Add the attribute back to the new XML, so that we can check the
  * removal against ACLs, and mark it as deleted for later removal after
  * differences have been calculated.
  *
  * \param[in,out] new_xml     XML to modify
  * \param[in]     element     Name of XML element that changed (for logging)
  * \param[in]     attr_name   Name of attribute that was deleted
  * \param[in]     old_value   Value of attribute that was deleted
  */
 static void
 mark_attr_deleted(xmlNode *new_xml, const char *element, const char *attr_name,
                   const char *old_value)
 {
     xml_doc_private_t *docpriv = new_xml->doc->_private;
     xmlAttr *attr = NULL;
     xml_node_private_t *nodepriv;
 
     /* Restore the old value (without setting dirty flag recursively upwards or
      * checking ACLs)
      */
     pcmk__clear_xml_flags(docpriv, pcmk__xf_tracking);
     crm_xml_add(new_xml, attr_name, old_value);
     pcmk__set_xml_flags(docpriv, pcmk__xf_tracking);
 
     // Reset flags (so the attribute doesn't appear as newly created)
     attr = xmlHasProp(new_xml, (pcmkXmlStr) attr_name);
     nodepriv = attr->_private;
     nodepriv->flags = 0;
 
     // Check ACLs and mark restored value for later removal
-    remove_xe_attr(new_xml, attr);
+    pcmk__xa_remove(attr);
 
     crm_trace("XML attribute %s=%s was removed from %s",
               attr_name, old_value, element);
 }
 
 /*
  * \internal
  * \brief Check ACLs for a changed XML attribute
  */
 static void
 mark_attr_changed(xmlNode *new_xml, const char *element, const char *attr_name,
                   const char *old_value)
 {
     xml_doc_private_t *docpriv = new_xml->doc->_private;
     char *vcopy = crm_element_value_copy(new_xml, attr_name);
 
     crm_trace("XML attribute %s was changed from '%s' to '%s' in %s",
               attr_name, old_value, vcopy, element);
 
     // Restore the original value (without checking ACLs)
     pcmk__clear_xml_flags(docpriv, pcmk__xf_tracking);
     crm_xml_add(new_xml, attr_name, old_value);
     pcmk__set_xml_flags(docpriv, pcmk__xf_tracking);
 
     // Change it back to the new value, to check ACLs
     crm_xml_add(new_xml, attr_name, vcopy);
     free(vcopy);
 }
 
 /*!
  * \internal
  * \brief Mark an XML attribute as having changed position
  *
  * \param[in,out] new_xml     XML to modify
  * \param[in]     element     Name of XML element that changed (for logging)
  * \param[in,out] old_attr    Attribute that moved, in original XML
  * \param[in,out] new_attr    Attribute that moved, in \p new_xml
  * \param[in]     p_old       Ordinal position of \p old_attr in original XML
  * \param[in]     p_new       Ordinal position of \p new_attr in \p new_xml
  */
 static void
 mark_attr_moved(xmlNode *new_xml, const char *element, xmlAttr *old_attr,
                 xmlAttr *new_attr, int p_old, int p_new)
 {
     xml_node_private_t *nodepriv = new_attr->_private;
 
     crm_trace("XML attribute %s moved from position %d to %d in %s",
               old_attr->name, p_old, p_new, element);
 
     // Mark document, element, and all element's parents as changed
     pcmk__mark_xml_node_dirty(new_xml);
 
     // Mark attribute as changed
     pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_moved);
 
     nodepriv = (p_old > p_new)? old_attr->_private : new_attr->_private;
     pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 }
 
 /*!
  * \internal
  * \brief Calculate differences in all previously existing XML attributes
  *
  * \param[in,out] old_xml  Original XML to compare
  * \param[in,out] new_xml  New XML to compare
  */
 static void
 xml_diff_old_attrs(xmlNode *old_xml, xmlNode *new_xml)
 {
     xmlAttr *attr_iter = pcmk__xe_first_attr(old_xml);
 
     while (attr_iter != NULL) {
         const char *name = (const char *) attr_iter->name;
         xmlAttr *old_attr = attr_iter;
         xmlAttr *new_attr = xmlHasProp(new_xml, attr_iter->name);
         const char *old_value = pcmk__xml_attr_value(attr_iter);
 
         attr_iter = attr_iter->next;
         if (new_attr == NULL) {
             mark_attr_deleted(new_xml, (const char *) old_xml->name, name,
                               old_value);
 
         } else {
             xml_node_private_t *nodepriv = new_attr->_private;
             int new_pos = pcmk__xml_position((xmlNode*) new_attr,
                                              pcmk__xf_skip);
             int old_pos = pcmk__xml_position((xmlNode*) old_attr,
                                              pcmk__xf_skip);
             const char *new_value = crm_element_value(new_xml, name);
 
             // This attribute isn't new
             pcmk__clear_xml_flags(nodepriv, pcmk__xf_created);
 
             if (strcmp(new_value, old_value) != 0) {
                 mark_attr_changed(new_xml, (const char *) old_xml->name, name,
                                   old_value);
 
             } else if ((old_pos != new_pos)
                        && !pcmk__tracking_xml_changes(new_xml, TRUE)) {
                 mark_attr_moved(new_xml, (const char *) old_xml->name,
                                 old_attr, new_attr, old_pos, new_pos);
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Check all attributes in new XML for creation
  *
  * For each of a given XML element's attributes marked as newly created, accept
  * (and mark as dirty) or reject the creation according to ACLs.
  *
  * \param[in,out] new_xml  XML to check
  */
 static void
 mark_created_attrs(xmlNode *new_xml)
 {
     xmlAttr *attr_iter = pcmk__xe_first_attr(new_xml);
 
     while (attr_iter != NULL) {
         xmlAttr *new_attr = attr_iter;
         xml_node_private_t *nodepriv = attr_iter->_private;
 
         attr_iter = attr_iter->next;
         if (pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
             const char *attr_name = (const char *) new_attr->name;
 
             crm_trace("Created new attribute %s=%s in %s",
                       attr_name, pcmk__xml_attr_value(new_attr),
                       new_xml->name);
 
             /* Check ACLs (we can't use the remove-then-create trick because it
              * would modify the attribute position).
              */
             if (pcmk__check_acl(new_xml, attr_name, pcmk__xf_acl_write)) {
                 pcmk__mark_xml_attr_dirty(new_attr);
             } else {
                 // Creation was not allowed, so remove the attribute
                 xmlRemoveProp(new_attr);
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Calculate differences in attributes between two XML nodes
  *
  * \param[in,out] old_xml  Original XML to compare
  * \param[in,out] new_xml  New XML to compare
  */
 static void
 xml_diff_attrs(xmlNode *old_xml, xmlNode *new_xml)
 {
     set_attrs_flag(new_xml, pcmk__xf_created); // cleared later if not really new
     xml_diff_old_attrs(old_xml, new_xml);
     mark_created_attrs(new_xml);
 }
 
 /*!
  * \internal
  * \brief Add an XML child element to a node, marked as deleted
  *
  * When calculating XML changes, we need to know when a child element has been
  * deleted. Add the child back to the new XML, so that we can check the removal
  * against ACLs, and mark it as deleted for later removal after differences have
  * been calculated.
  *
  * \param[in,out] old_child    Child element from original XML
  * \param[in,out] new_parent   New XML to add marked copy to
  */
 static void
 mark_child_deleted(xmlNode *old_child, xmlNode *new_parent)
 {
     // Re-create the child element so we can check ACLs
     xmlNode *candidate = pcmk__xml_copy(new_parent, old_child);
 
     // Clear flags on new child and its children
     pcmk__xml_tree_foreach(candidate, reset_xml_node_flags, NULL);
 
     // Check whether ACLs allow the deletion
     pcmk__apply_acl(xmlDocGetRootElement(candidate->doc));
 
     // Remove the child again (which will track it in document's deleted_objs)
     free_xml_with_position(candidate,
                            pcmk__xml_position(old_child, pcmk__xf_skip));
 
     if (pcmk__xml_match(new_parent, old_child, true) == NULL) {
         pcmk__set_xml_flags((xml_node_private_t *) (old_child->_private),
                             pcmk__xf_skip);
     }
 }
 
 static void
 mark_child_moved(xmlNode *old_child, xmlNode *new_parent, xmlNode *new_child,
                  int p_old, int p_new)
 {
     xml_node_private_t *nodepriv = new_child->_private;
 
     crm_trace("Child element %s with "
               PCMK_XA_ID "='%s' moved from position %d to %d under %s",
               new_child->name, pcmk__s(pcmk__xe_id(new_child), "<no id>"),
               p_old, p_new, new_parent->name);
     pcmk__mark_xml_node_dirty(new_parent);
     pcmk__set_xml_flags(nodepriv, pcmk__xf_moved);
 
     if (p_old > p_new) {
         nodepriv = old_child->_private;
     } else {
         nodepriv = new_child->_private;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 }
 
 // Given original and new XML, mark new XML portions that have changed
 static void
 mark_xml_changes(xmlNode *old_xml, xmlNode *new_xml, bool check_top)
 {
     xmlNode *old_child = NULL;
     xmlNode *new_child = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     CRM_CHECK(new_xml != NULL, return);
     if (old_xml == NULL) {
         pcmk__xml_mark_created(new_xml);
         pcmk__apply_creation_acl(new_xml, check_top);
         return;
     }
 
     nodepriv = new_xml->_private;
     CRM_CHECK(nodepriv != NULL, return);
 
     if(nodepriv->flags & pcmk__xf_processed) {
         /* Avoid re-comparing nodes */
         return;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_processed);
 
     xml_diff_attrs(old_xml, new_xml);
 
     // Check for differences in the original children
     for (old_child = pcmk__xml_first_child(old_xml); old_child != NULL;
          old_child = pcmk__xml_next(old_child)) {
 
         new_child = pcmk__xml_match(new_xml, old_child, true);
 
         if (new_child != NULL) {
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             mark_child_deleted(old_child, new_xml);
         }
     }
 
     // Check for moved or created children
     new_child = pcmk__xml_first_child(new_xml);
     while (new_child != NULL) {
         xmlNode *next = pcmk__xml_next(new_child);
 
         old_child = pcmk__xml_match(old_xml, new_child, true);
 
         if (old_child == NULL) {
             // This is a newly created child
             nodepriv = new_child->_private;
             pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 
             // May free new_child
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             /* Check for movement, we already checked for differences */
             int p_new = pcmk__xml_position(new_child, pcmk__xf_skip);
             int p_old = pcmk__xml_position(old_child, pcmk__xf_skip);
 
             if(p_old != p_new) {
                 mark_child_moved(old_child, new_xml, new_child, p_old, p_new);
             }
         }
 
         new_child = next;
     }
 }
 
 void
 xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     pcmk__set_xml_doc_flag(new_xml, pcmk__xf_lazy);
     xml_calculate_changes(old_xml, new_xml);
 }
 
 // Called functions may set the \p pcmk__xf_skip flag on parts of \p old_xml
 void
 xml_calculate_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     CRM_CHECK((old_xml != NULL) && (new_xml != NULL)
               && pcmk__xe_is(old_xml, (const char *) new_xml->name)
               && pcmk__str_eq(pcmk__xe_id(old_xml), pcmk__xe_id(new_xml),
                               pcmk__str_none),
               return);
 
     if(xml_tracking_changes(new_xml) == FALSE) {
         xml_track_changes(new_xml, NULL, NULL, FALSE);
     }
 
     mark_xml_changes(old_xml, new_xml, FALSE);
 }
 
 /*!
  * \internal
  * \brief Find a comment with matching content in specified XML
  *
  * \param[in] root            XML to search
  * \param[in] search_comment  Comment whose content should be searched for
  * \param[in] exact           If true, comment must also be at same position
  */
 xmlNode *
 pcmk__xc_match(const xmlNode *root, const xmlNode *search_comment, bool exact)
 {
     xmlNode *a_child = NULL;
     int search_offset = pcmk__xml_position(search_comment, pcmk__xf_skip);
 
     CRM_CHECK(search_comment->type == XML_COMMENT_NODE, return NULL);
 
     for (a_child = pcmk__xml_first_child(root); a_child != NULL;
          a_child = pcmk__xml_next(a_child)) {
         if (exact) {
             int offset = pcmk__xml_position(a_child, pcmk__xf_skip);
             xml_node_private_t *nodepriv = a_child->_private;
 
             if (offset < search_offset) {
                 continue;
 
             } else if (offset > search_offset) {
                 return NULL;
             }
 
             if (pcmk_is_set(nodepriv->flags, pcmk__xf_skip)) {
                 continue;
             }
         }
 
         if (a_child->type == XML_COMMENT_NODE
             && pcmk__str_eq((const char *)a_child->content, (const char *)search_comment->content, pcmk__str_casei)) {
             return a_child;
 
         } else if (exact) {
             return NULL;
         }
     }
 
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Make one XML comment match another (in content)
  *
  * \param[in,out] parent   If \p target is NULL and this is not, add or update
  *                         comment child of this XML node that matches \p update
  * \param[in,out] target   If not NULL, update this XML comment node
  * \param[in]     update   Make comment content match this (must not be NULL)
  *
  * \note At least one of \parent and \target must be non-NULL
  */
 void
 pcmk__xc_update(xmlNode *parent, xmlNode *target, xmlNode *update)
 {
     CRM_CHECK(update != NULL, return);
     CRM_CHECK(update->type == XML_COMMENT_NODE, return);
 
     if (target == NULL) {
         target = pcmk__xc_match(parent, update, false);
     }
 
     if (target == NULL) {
         pcmk__xml_copy(parent, update);
 
     } else if (!pcmk__str_eq((const char *)target->content, (const char *)update->content, pcmk__str_casei)) {
         xmlFree(target->content);
         target->content = xmlStrdup(update->content);
     }
 }
 
 /*!
  * \internal
  * \brief Merge one XML tree into another
  *
  * Here, "merge" means:
  * 1. Copy attribute values from \p update to the target, overwriting in case of
  *    conflict.
  * 2. Descend through \p update and the target in parallel. At each level, for
  *    each child of \p update, look for a matching child of the target.
  *    a. For each child, if a match is found, go to step 1, recursively merging
  *       the child of \p update into the child of the target.
  *    b. Otherwise, copy the child of \p update as a child of the target.
  *
  * A match is defined as the first child of the same type within the target,
  * with:
  * * the \c PCMK_XA_ID attribute matching, if set in \p update; otherwise,
  * * the \c PCMK_XA_ID_REF attribute matching, if set in \p update
  *
  * This function does not delete any elements or attributes from the target. It
  * may add elements or overwrite attributes, as described above.
  *
  * \param[in,out] parent   If \p target is NULL and this is not, add or update
  *                         child of this XML node that matches \p update
  * \param[in,out] target   If not NULL, update this XML
  * \param[in]     update   Make the desired XML match this (must not be \c NULL)
  * \param[in]     flags    Group of <tt>enum pcmk__xa_flags</tt>
  *
  * \note At least one of \p parent and \p target must be non-<tt>NULL</tt>.
  * \note This function is recursive. For the top-level call, \p parent is
  *       \c NULL and \p target is not \c NULL. For recursive calls, \p target is
  *       \c NULL and \p parent is not \c NULL.
  */
 static void
 update_xe(xmlNode *parent, xmlNode *target, xmlNode *update, uint32_t flags)
 {
     // @TODO Try to refactor further, possibly using pcmk__xml_tree_foreach()
     const char *update_name = NULL;
     const char *update_id_attr = NULL;
     const char *update_id_val = NULL;
     char *trace_s = NULL;
 
     crm_log_xml_trace(update, "update");
     crm_log_xml_trace(target, "target");
 
     CRM_CHECK(update != NULL, goto done);
 
     if (update->type == XML_COMMENT_NODE) {
         pcmk__xc_update(parent, target, update);
         goto done;
     }
 
     update_name = (const char *) update->name;
 
     CRM_CHECK(update_name != NULL, goto done);
     CRM_CHECK((target != NULL) || (parent != NULL), goto done);
 
     update_id_val = pcmk__xe_id(update);
     if (update_id_val != NULL) {
         update_id_attr = PCMK_XA_ID;
 
     } else {
         update_id_val = crm_element_value(update, PCMK_XA_ID_REF);
         if (update_id_val != NULL) {
             update_id_attr = PCMK_XA_ID_REF;
         }
     }
 
     pcmk__if_tracing(
         {
             if (update_id_attr != NULL) {
                 trace_s = crm_strdup_printf("<%s %s=%s/>",
                                             update_name, update_id_attr,
                                             update_id_val);
             } else {
                 trace_s = crm_strdup_printf("<%s/>", update_name);
             }
         },
         {}
     );
 
     if (target == NULL) {
         // Recursive call
         target = pcmk__xe_first_child(parent, update_name, update_id_attr,
                                       update_id_val);
     }
 
     if (target == NULL) {
         // Recursive call with no existing matching child
         target = pcmk__xe_create(parent, update_name);
         crm_trace("Added %s", pcmk__s(trace_s, update_name));
 
     } else {
         // Either recursive call with match, or top-level call
         crm_trace("Found node %s to update", pcmk__s(trace_s, update_name));
     }
 
     CRM_CHECK(pcmk__xe_is(target, (const char *) update->name), return);
 
     pcmk__xe_copy_attrs(target, update, flags);
 
     for (xmlNode *child = pcmk__xml_first_child(update); child != NULL;
          child = pcmk__xml_next(child)) {
 
         crm_trace("Updating child of %s", pcmk__s(trace_s, update_name));
         update_xe(target, NULL, child, flags);
     }
 
     crm_trace("Finished with %s", pcmk__s(trace_s, update_name));
 
 done:
     free(trace_s);
 }
 
 /*!
  * \internal
  * \brief Delete an XML subtree if it matches a search element
  *
  * A match is defined as follows:
  * * \p xml and \p user_data are both element nodes of the same type.
  * * If \p user_data has attributes set, \p xml has those attributes set to the
  *   same values. (\p xml may have additional attributes set to arbitrary
  *   values.)
  *
  * \param[in,out] xml        XML subtree to delete upon match
  * \param[in]     user_data  Search element
  *
  * \return \c true to continue traversing the tree, or \c false to stop (because
  *         \p xml was deleted)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 delete_xe_if_matching(xmlNode *xml, void *user_data)
 {
     xmlNode *search = user_data;
 
     if (!pcmk__xe_is(search, (const char *) xml->name)) {
         // No match: either not both elements, or different element types
         return true;
     }
 
     for (const xmlAttr *attr = pcmk__xe_first_attr(search); attr != NULL;
          attr = attr->next) {
 
         const char *search_val = pcmk__xml_attr_value(attr);
         const char *xml_val = crm_element_value(xml, (const char *) attr->name);
 
         if (!pcmk__str_eq(search_val, xml_val, pcmk__str_casei)) {
             // No match: an attr in xml doesn't match the attr in search
             return true;
         }
     }
 
     crm_log_xml_trace(xml, "delete-match");
     crm_log_xml_trace(search, "delete-search");
     pcmk__xml_free(xml);
 
     // Found a match and deleted it; stop traversing tree
     return false;
 }
 
 /*!
  * \internal
  * \brief Search an XML tree depth-first and delete the first matching element
  *
  * This function does not attempt to match the tree root (\p xml).
  *
  * A match with a node \c node is defined as follows:
  * * \c node and \p search are both element nodes of the same type.
  * * If \p search has attributes set, \c node has those attributes set to the
  *   same values. (\c node may have additional attributes set to arbitrary
  *   values.)
  *
  * \param[in,out] xml     XML subtree to search
  * \param[in]     search  Element to match against
  *
  * \return Standard Pacemaker return code (specifically, \c pcmk_rc_ok on
  *         successful deletion and an error code otherwise)
  */
 int
 pcmk__xe_delete_match(xmlNode *xml, xmlNode *search)
 {
     // See @COMPAT comment in pcmk__xe_replace_match()
     CRM_CHECK((xml != NULL) && (search != NULL), return EINVAL);
 
     for (xml = pcmk__xe_first_child(xml, NULL, NULL, NULL); xml != NULL;
          xml = pcmk__xe_next(xml)) {
 
         if (!pcmk__xml_tree_foreach(xml, delete_xe_if_matching, search)) {
             // Found and deleted an element
             return pcmk_rc_ok;
         }
     }
 
     // No match found in this subtree
     return ENXIO;
 }
 
 /*!
  * \internal
  * \brief Replace one XML node with a copy of another XML node
  *
  * This function handles change tracking and applies ACLs.
  *
  * \param[in,out] old  XML node to replace
  * \param[in]     new  XML node to copy as replacement for \p old
  *
  * \note This frees \p old.
  */
 static void
 replace_node(xmlNode *old, xmlNode *new)
 {
     new = xmlCopyNode(new, 1);
     pcmk__mem_assert(new);
 
     // May be unnecessary but avoids slight changes to some test outputs
     pcmk__xml_tree_foreach(new, reset_xml_node_flags, NULL);
 
     old = xmlReplaceNode(old, new);
 
     if (xml_tracking_changes(new)) {
         // Replaced sections may have included relevant ACLs
         pcmk__apply_acl(new);
     }
     xml_calculate_changes(old, new);
     free_xml_node(old);
 }
 
 /*!
  * \internal
  * \brief Replace one XML subtree with a copy of another if the two match
  *
  * A match is defined as follows:
  * * \p xml and \p user_data are both element nodes of the same type.
  * * If \p user_data has the \c PCMK_XA_ID attribute set, then \p xml has
  *   \c PCMK_XA_ID set to the same value.
  *
  * \param[in,out] xml        XML subtree to replace with \p user_data upon match
  * \param[in]     user_data  XML to replace \p xml with a copy of upon match
  *
  * \return \c true to continue traversing the tree, or \c false to stop (because
  *         \p xml was replaced by \p user_data)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 replace_xe_if_matching(xmlNode *xml, void *user_data)
 {
     xmlNode *replace = user_data;
     const char *xml_id = NULL;
     const char *replace_id = NULL;
 
     xml_id = pcmk__xe_id(xml);
     replace_id = pcmk__xe_id(replace);
 
     if (!pcmk__xe_is(replace, (const char *) xml->name)) {
         // No match: either not both elements, or different element types
         return true;
     }
 
     if ((replace_id != NULL)
         && !pcmk__str_eq(replace_id, xml_id, pcmk__str_none)) {
 
         // No match: ID was provided in replace and doesn't match xml's ID
         return true;
     }
 
     crm_log_xml_trace(xml, "replace-match");
     crm_log_xml_trace(replace, "replace-with");
     replace_node(xml, replace);
 
     // Found a match and replaced it; stop traversing tree
     return false;
 }
 
 /*!
  * \internal
  * \brief Search an XML tree depth-first and replace the first matching element
  *
  * This function does not attempt to match the tree root (\p xml).
  *
  * A match with a node \c node is defined as follows:
  * * \c node and \p replace are both element nodes of the same type.
  * * If \p replace has the \c PCMK_XA_ID attribute set, then \c node has
  *   \c PCMK_XA_ID set to the same value.
  *
  * \param[in,out] xml      XML tree to search
  * \param[in]     replace  XML to replace a matching element with a copy of
  *
  * \return Standard Pacemaker return code (specifically, \c pcmk_rc_ok on
  *         successful replacement and an error code otherwise)
  */
 int
 pcmk__xe_replace_match(xmlNode *xml, xmlNode *replace)
 {
     /* @COMPAT Some of this behavior (like not matching the tree root, which is
      * allowed by pcmk__xe_update_match()) is questionable for general use but
      * required for backward compatibility by cib_process_replace() and
      * cib_process_delete(). Behavior can change at a major version release if
      * desired.
      */
     CRM_CHECK((xml != NULL) && (replace != NULL), return EINVAL);
 
     for (xml = pcmk__xe_first_child(xml, NULL, NULL, NULL); xml != NULL;
          xml = pcmk__xe_next(xml)) {
 
         if (!pcmk__xml_tree_foreach(xml, replace_xe_if_matching, replace)) {
             // Found and replaced an element
             return pcmk_rc_ok;
         }
     }
 
     // No match found in this subtree
     return ENXIO;
 }
 
 //! User data for \c update_xe_if_matching()
 struct update_data {
     xmlNode *update;    //!< Update source
     uint32_t flags;     //!< Group of <tt>enum pcmk__xa_flags</tt>
 };
 
 /*!
  * \internal
  * \brief Update one XML subtree with another if the two match
  *
  * "Update" means to merge a source subtree into a target subtree (see
  * \c update_xe()).
  *
  * A match is defined as follows:
  * * \p xml and \p user_data->update are both element nodes of the same type.
  * * \p xml and \p user_data->update have the same \c PCMK_XA_ID attribute
  *   value, or \c PCMK_XA_ID is unset in both
  *
  * \param[in,out] xml        XML subtree to update with \p user_data->update
  *                           upon match
  * \param[in]     user_data  <tt>struct update_data</tt> object
  *
  * \return \c true to continue traversing the tree, or \c false to stop (because
  *         \p xml was updated by \p user_data->update)
  *
  * \note This is compatible with \c pcmk__xml_tree_foreach().
  */
 static bool
 update_xe_if_matching(xmlNode *xml, void *user_data)
 {
     struct update_data *data = user_data;
     xmlNode *update = data->update;
 
     if (!pcmk__xe_is(update, (const char *) xml->name)) {
         // No match: either not both elements, or different element types
         return true;
     }
 
     if (!pcmk__str_eq(pcmk__xe_id(xml), pcmk__xe_id(update), pcmk__str_none)) {
         // No match: ID mismatch
         return true;
     }
 
     crm_log_xml_trace(xml, "update-match");
     crm_log_xml_trace(update, "update-with");
     update_xe(NULL, xml, update, data->flags);
 
     // Found a match and replaced it; stop traversing tree
     return false;
 }
 
 /*!
  * \internal
  * \brief Search an XML tree depth-first and update the first matching element
  *
  * "Update" means to merge a source subtree into a target subtree (see
  * \c update_xe()).
  *
  * A match with a node \c node is defined as follows:
  * * \c node and \p update are both element nodes of the same type.
  * * \c node and \p update have the same \c PCMK_XA_ID attribute value, or
  *   \c PCMK_XA_ID is unset in both
  *
  * \param[in,out] xml     XML tree to search
  * \param[in]     update  XML to update a matching element with
  * \param[in]     flags   Group of <tt>enum pcmk__xa_flags</tt>
  *
  * \return Standard Pacemaker return code (specifically, \c pcmk_rc_ok on
  *         successful update and an error code otherwise)
  */
 int
 pcmk__xe_update_match(xmlNode *xml, xmlNode *update, uint32_t flags)
 {
     /* @COMPAT In pcmk__xe_delete_match() and pcmk__xe_replace_match(), we
      * compare IDs only if the equivalent of the update argument has an ID.
      * Here, we're stricter: we consider it a mismatch if only one element has
      * an ID attribute, or if both elements have IDs but they don't match.
      *
      * Perhaps we should align the behavior at a major version release.
      */
     struct update_data data = {
         .update = update,
         .flags = flags,
     };
 
     CRM_CHECK((xml != NULL) && (update != NULL), return EINVAL);
 
     if (!pcmk__xml_tree_foreach(xml, update_xe_if_matching, &data)) {
         // Found and updated an element
         return pcmk_rc_ok;
     }
 
     // No match found in this subtree
     return ENXIO;
 }
 
 /*!
  * \internal
  * \brief Get next sibling XML element with the same name as a given element
  *
  * \param[in] node  XML element to start from
  *
  * \return Next sibling XML element with same name
  */
 xmlNode *
 pcmk__xe_next_same(const xmlNode *node)
 {
     for (xmlNode *match = pcmk__xe_next(node); match != NULL;
          match = pcmk__xe_next(match)) {
 
         if (pcmk__xe_is(match, (const char *) node->name)) {
             return match;
         }
     }
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Initialize the Pacemaker XML environment
  *
  * Set an XML buffer allocation scheme, set XML node create and destroy
  * callbacks, and load schemas into the cache.
  */
 void
 pcmk__xml_init(void)
 {
     // @TODO Try to find a better caller than crm_log_preinit()
     static bool initialized = false;
 
     if (!initialized) {
         initialized = true;
 
         /* Double the buffer size when the buffer needs to grow. The default
          * allocator XML_BUFFER_ALLOC_EXACT was found to cause poor performance
          * due to the number of reallocs.
          */
         xmlSetBufferAllocationScheme(XML_BUFFER_ALLOC_DOUBLEIT);
 
         // Initialize private data at node creation
         xmlRegisterNodeDefault(new_private_data);
 
         // Free private data at node destruction
         xmlDeregisterNodeDefault(free_private_data);
 
         // Load schemas into the cache
         pcmk__schema_init();
     }
 }
 
 /*!
  * \internal
  * \brief Tear down the Pacemaker XML environment
  *
  * Destroy schema cache and clean up memory allocated by libxml2.
  */
 void
 pcmk__xml_cleanup(void)
 {
     pcmk__schema_cleanup();
     xmlCleanupParser();
 }
 
 char *
 pcmk__xml_artefact_root(enum pcmk__xml_artefact_ns ns)
 {
     static const char *base = NULL;
     char *ret = NULL;
 
     if (base == NULL) {
         base = pcmk__env_option(PCMK__ENV_SCHEMA_DIRECTORY);
     }
     if (pcmk__str_empty(base)) {
         base = PCMK_SCHEMA_DIR;
     }
 
     switch (ns) {
         case pcmk__xml_artefact_ns_legacy_rng:
         case pcmk__xml_artefact_ns_legacy_xslt:
             ret = strdup(base);
             break;
         case pcmk__xml_artefact_ns_base_rng:
         case pcmk__xml_artefact_ns_base_xslt:
             ret = crm_strdup_printf("%s/base", base);
             break;
         default:
             crm_err("XML artefact family specified as %u not recognized", ns);
     }
     return ret;
 }
 
 static char *
 find_artefact(enum pcmk__xml_artefact_ns ns, const char *path, const char *filespec)
 {
     char *ret = NULL;
 
     switch (ns) {
         case pcmk__xml_artefact_ns_legacy_rng:
         case pcmk__xml_artefact_ns_base_rng:
             if (pcmk__ends_with(filespec, ".rng")) {
                 ret = crm_strdup_printf("%s/%s", path, filespec);
             } else {
                 ret = crm_strdup_printf("%s/%s.rng", path, filespec);
             }
             break;
         case pcmk__xml_artefact_ns_legacy_xslt:
         case pcmk__xml_artefact_ns_base_xslt:
             if (pcmk__ends_with(filespec, ".xsl")) {
                 ret = crm_strdup_printf("%s/%s", path, filespec);
             } else {
                 ret = crm_strdup_printf("%s/%s.xsl", path, filespec);
             }
             break;
         default:
             crm_err("XML artefact family specified as %u not recognized", ns);
     }
 
     return ret;
 }
 
 char *
 pcmk__xml_artefact_path(enum pcmk__xml_artefact_ns ns, const char *filespec)
 {
     struct stat sb;
     char *base = pcmk__xml_artefact_root(ns);
     char *ret = NULL;
 
     ret = find_artefact(ns, base, filespec);
     free(base);
 
     if (stat(ret, &sb) != 0 || !S_ISREG(sb.st_mode)) {
         const char *remote_schema_dir = pcmk__remote_schema_dir();
 
         free(ret);
         ret = find_artefact(ns, remote_schema_dir, filespec);
     }
 
     return ret;
 }
 
 void
 pcmk__xe_set_propv(xmlNodePtr node, va_list pairs)
 {
     while (true) {
         const char *name, *value;
 
         name = va_arg(pairs, const char *);
         if (name == NULL) {
             return;
         }
 
         value = va_arg(pairs, const char *);
         if (value != NULL) {
             crm_xml_add(node, name, value);
         }
     }
 }
 
 void
 pcmk__xe_set_props(xmlNodePtr node, ...)
 {
     va_list pairs;
     va_start(pairs, node);
     pcmk__xe_set_propv(node, pairs);
     va_end(pairs);
 }
 
 int
 pcmk__xe_foreach_child(xmlNode *xml, const char *child_element_name,
                        int (*handler)(xmlNode *xml, void *userdata),
                        void *userdata)
 {
     xmlNode *children = (xml? xml->children : NULL);
 
     CRM_ASSERT(handler != NULL);
 
     for (xmlNode *node = children; node != NULL; node = node->next) {
         if ((node->type == XML_ELEMENT_NODE)
             && ((child_element_name == NULL)
                 || pcmk__xe_is(node, child_element_name))) {
             int rc = handler(node, userdata);
 
             if (rc != pcmk_rc_ok) {
                 return rc;
             }
         }
     }
 
     return pcmk_rc_ok;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <crm/common/nvpair_compat.h>
 #include <crm/common/xml_compat.h>
 
 xmlNode *
 copy_xml(xmlNode *src)
 {
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNode *copy = NULL;
 
     copy = xmlDocCopyNode(src, doc, 1);
     pcmk__mem_assert(copy);
 
     xmlDocSetRootElement(doc, copy);
     return copy;
 }
 
 void
 crm_xml_init(void)
 {
     pcmk__xml_init();
 }
 
 void
 crm_xml_cleanup(void)
 {
     pcmk__xml_cleanup();
 }
 
 void
 pcmk_free_xml_subtree(xmlNode *xml)
 {
     free_xml_node(xml);
 }
 
 void
 free_xml(xmlNode *child)
 {
     pcmk__xml_free(child);
 }
 
 xmlNode *
 expand_idref(xmlNode *input, xmlNode *top)
 {
     return pcmk__xe_resolve_idref(input, top);
 }
 
 void
 crm_xml_sanitize_id(char *id)
 {
     char *c;
 
     for (c = id; *c; ++c) {
         switch (*c) {
             case ':':
             case '#':
                 *c = '.';
         }
     }
 }
 
 void
 crm_xml_set_id(xmlNode *xml, const char *format, ...)
 {
     va_list ap;
     int len = 0;
     char *id = NULL;
 
     /* equivalent to crm_strdup_printf() */
     va_start(ap, format);
     len = vasprintf(&id, format, ap);
     va_end(ap);
     CRM_ASSERT(len > 0);
 
     crm_xml_sanitize_id(id);
     crm_xml_add(xml, PCMK_XA_ID, id);
     free(id);
 }
 
 xmlNode *
 sorted_xml(xmlNode *input, xmlNode *parent, gboolean recursive)
 {
     xmlNode *child = NULL;
     GSList *nvpairs = NULL;
     xmlNode *result = NULL;
 
     CRM_CHECK(input != NULL, return NULL);
 
     result = pcmk__xe_create(parent, (const char *) input->name);
     nvpairs = pcmk_xml_attrs2nvpairs(input);
     nvpairs = pcmk_sort_nvpairs(nvpairs);
     pcmk_nvpairs2xml_attrs(nvpairs, result);
     pcmk_free_nvpairs(nvpairs);
 
     for (child = pcmk__xe_first_child(input, NULL, NULL, NULL); child != NULL;
          child = pcmk__xe_next(child)) {
 
         if (recursive) {
             sorted_xml(child, result, recursive);
         } else {
             pcmk__xml_copy(result, child);
         }
     }
 
     return result;
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/lib/common/xml_attr.c b/lib/common/xml_attr.c
index faed37f2b2..8250f37708 100644
--- a/lib/common/xml_attr.c
+++ b/lib/common/xml_attr.c
@@ -1,95 +1,136 @@
 /*
  * 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>
 #include <sys/types.h>
 #include <unistd.h>
 #include <time.h>
 #include <string.h>
 #include <stdlib.h>
 #include <stdarg.h>
 #include <bzlib.h>
 
 #include <libxml/parser.h>
 #include <libxml/tree.h>
 #include <libxml/xmlIO.h>  /* xmlAllocOutputBuffer */
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>  // PCMK__XML_LOG_BASE, etc.
 #include "crmcommon_private.h"
 
+/*!
+ * \internal
+ * \brief Remove an XML attribute from its parent and free it
+ *
+ * \param[in,out] attr   XML attribute to remove
+ *
+ * \return Standard Pacemaker return code (\c EPERM if ACLs prevent removal, or
+ *         or \c pcmk_rc_ok otherwise)
+ *
+ * \note If the attribute has no parent element, this function does not free it.
+ *       This mimics \c xmlRemoveProp().
+ */
+int
+pcmk__xa_remove(xmlAttr *attr)
+{
+    xmlNode *element = NULL;
+
+    if ((attr == NULL) || (attr->parent == NULL)) {
+        return pcmk_rc_ok;
+    }
+
+    element = attr->parent;
+
+    if (!pcmk__check_acl(element, NULL, pcmk__xf_acl_write)) {
+        // ACLs apply to element, not to particular attributes
+        crm_trace("ACLs prevent removal of attributes from %s element",
+                  (const char *) element->name);
+        return EPERM;
+    }
+
+    if (pcmk__tracking_xml_changes(element, false)) {
+        // Leave in place (marked for removal) until after diff is calculated
+        pcmk__xml_set_parent_flags(element, pcmk__xf_dirty);
+        pcmk__set_xml_flags((xml_node_private_t *) attr->_private,
+                            pcmk__xf_deleted);
+    } else {
+        xmlRemoveProp(attr);
+    }
+    return pcmk_rc_ok;
+}
+
 void
 pcmk__mark_xml_attr_dirty(xmlAttr *a) 
 {
     xmlNode *parent = a->parent;
     xml_node_private_t *nodepriv = a->_private;
 
     pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_modified);
     pcmk__clear_xml_flags(nodepriv, pcmk__xf_deleted);
     pcmk__mark_xml_node_dirty(parent);
 }
 
 // This also clears attribute's flags if not marked as deleted
 bool
 pcmk__marked_as_deleted(xmlAttrPtr a, void *user_data)
 {
     xml_node_private_t *nodepriv = a->_private;
 
     if (pcmk_is_set(nodepriv->flags, pcmk__xf_deleted)) {
         return true;
     }
     nodepriv->flags = pcmk__xf_none;
     return false;
 }
 
 /*!
  * \internal
  * \brief Append an XML attribute to a buffer
  *
  * \param[in]     attr     Attribute to append
  * \param[in,out] buffer   Where to append the content (must not be \p NULL)
  */
 void
 pcmk__dump_xml_attr(const xmlAttr *attr, GString *buffer)
 {
     const char *name = NULL;
     const char *value = NULL;
     gchar *value_esc = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     if (attr == NULL || attr->children == NULL) {
         return;
     }
 
     nodepriv = attr->_private;
     if (nodepriv && pcmk_is_set(nodepriv->flags, pcmk__xf_deleted)) {
         return;
     }
 
     name = (const char *) attr->name;
     value = (const char *) attr->children->content;
     if (value == NULL) {
         /* Don't print anything for unset attribute. Any null-indicator value,
          * including the empty string, could also be a real value that needs to
          * be treated differently from "unset".
          */
         return;
     }
 
     if (pcmk__xml_needs_escape(value, pcmk__xml_escape_attr)) {
         value_esc = pcmk__xml_escape(value, pcmk__xml_escape_attr);
         value = value_esc;
     }
 
     pcmk__g_strcat(buffer, " ", name, "=\"", value, "\"", NULL);
     g_free(value_esc);
 }