diff --git a/daemons/based/based_callbacks.c b/daemons/based/based_callbacks.c
index d9f359b572..2b5be57f7d 100644
--- a/daemons/based/based_callbacks.c
+++ b/daemons/based/based_callbacks.c
@@ -1,1402 +1,1402 @@
 /*
  * Copyright 2004-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <sys/param.h>
 #include <stdio.h>
 #include <sys/types.h>
 #include <unistd.h>
 
 #include <stdlib.h>
 #include <stdint.h>     // uint32_t, uint64_t, UINT64_C()
 #include <errno.h>
 #include <fcntl.h>
 #include <inttypes.h>   // PRIu64
 
 #include <glib.h>
 #include <libxml/tree.h>
 #include <libxml/xpath.h>               // xmlXPathObject, etc.
 
 #include <crm/crm.h>
 #include <crm/cib.h>
 #include <crm/cluster/internal.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/remote_internal.h>
 
 #include <pacemaker-based.h>
 
 #define EXIT_ESCALATION_MS 10000
 
 qb_ipcs_service_t *ipcs_ro = NULL;
 qb_ipcs_service_t *ipcs_rw = NULL;
 qb_ipcs_service_t *ipcs_shm = NULL;
 
 static int cib_process_command(xmlNode *request,
                                const cib__operation_t *operation,
                                cib__op_fn_t op_function, xmlNode **reply,
                                xmlNode **cib_diff, bool privileged);
 
 static gboolean cib_common_callback(qb_ipcs_connection_t *c, void *data,
                                     size_t size, gboolean privileged);
 
 static int32_t
 cib_ipc_accept(qb_ipcs_connection_t * c, uid_t uid, gid_t gid)
 {
     if (cib_shutdown_flag) {
         crm_info("Ignoring new IPC client [%d] during shutdown",
                  pcmk__client_pid(c));
         return -ECONNREFUSED;
     }
 
     if (pcmk__new_client(c, uid, gid) == NULL) {
         return -ENOMEM;
     }
     return 0;
 }
 
 static int32_t
 cib_ipc_dispatch_rw(qb_ipcs_connection_t * c, void *data, size_t size)
 {
     pcmk__client_t *client = pcmk__find_client(c);
 
     crm_trace("%p message from %s", c, client->id);
     return cib_common_callback(c, data, size, TRUE);
 }
 
 static int32_t
 cib_ipc_dispatch_ro(qb_ipcs_connection_t * c, void *data, size_t size)
 {
     pcmk__client_t *client = pcmk__find_client(c);
 
     crm_trace("%p message from %s", c, client->id);
     return cib_common_callback(c, data, size, FALSE);
 }
 
 /* Error code means? */
 static int32_t
 cib_ipc_closed(qb_ipcs_connection_t * c)
 {
     pcmk__client_t *client = pcmk__find_client(c);
 
     if (client == NULL) {
         return 0;
     }
     crm_trace("Connection %p", c);
     pcmk__free_client(client);
     return 0;
 }
 
 static void
 cib_ipc_destroy(qb_ipcs_connection_t * c)
 {
     crm_trace("Connection %p", c);
     cib_ipc_closed(c);
     if (cib_shutdown_flag) {
         cib_shutdown(0);
     }
 }
 
 struct qb_ipcs_service_handlers ipc_ro_callbacks = {
     .connection_accept = cib_ipc_accept,
     .connection_created = NULL,
     .msg_process = cib_ipc_dispatch_ro,
     .connection_closed = cib_ipc_closed,
     .connection_destroyed = cib_ipc_destroy
 };
 
 struct qb_ipcs_service_handlers ipc_rw_callbacks = {
     .connection_accept = cib_ipc_accept,
     .connection_created = NULL,
     .msg_process = cib_ipc_dispatch_rw,
     .connection_closed = cib_ipc_closed,
     .connection_destroyed = cib_ipc_destroy
 };
 
 /*!
  * \internal
  * \brief Create reply XML for a CIB request
  *
  * \param[in] op            CIB operation type
  * \param[in] call_id       CIB call ID
  * \param[in] client_id     CIB client ID
  * \param[in] call_options  Group of <tt>enum cib_call_options</tt> flags
  * \param[in] rc            Request return code
  * \param[in] call_data     Request output data
  *
  * \return Reply XML (guaranteed not to be \c NULL)
  *
  * \note The caller is responsible for freeing the return value using
  *       \p pcmk__xml_free().
  */
 static xmlNode *
 create_cib_reply(const char *op, const char *call_id, const char *client_id,
                  uint32_t call_options, int rc, xmlNode *call_data)
 {
     xmlNode *reply = pcmk__xe_create(NULL, PCMK__XE_CIB_REPLY);
 
     crm_xml_add(reply, PCMK__XA_T, PCMK__VALUE_CIB);
     crm_xml_add(reply, PCMK__XA_CIB_OP, op);
     crm_xml_add(reply, PCMK__XA_CIB_CALLID, call_id);
     crm_xml_add(reply, PCMK__XA_CIB_CLIENTID, client_id);
     crm_xml_add_int(reply, PCMK__XA_CIB_CALLOPT, call_options);
     crm_xml_add_int(reply, PCMK__XA_CIB_RC, rc);
 
     if (call_data != NULL) {
         xmlNode *wrapper = pcmk__xe_create(reply, PCMK__XE_CIB_CALLDATA);
 
         crm_trace("Attaching reply output");
         pcmk__xml_copy(wrapper, call_data);
     }
 
     crm_log_xml_explicit(reply, "cib:reply");
     return reply;
 }
 
 static void
 do_local_notify(const xmlNode *notify_src, const char *client_id,
                 bool sync_reply, bool from_peer)
 {
     int msg_id = 0;
     int rc = pcmk_rc_ok;
     pcmk__client_t *client_obj = NULL;
     uint32_t flags = crm_ipc_server_event;
 
     CRM_CHECK((notify_src != NULL) && (client_id != NULL), return);
 
     crm_element_value_int(notify_src, PCMK__XA_CIB_CALLID, &msg_id);
 
     client_obj = pcmk__find_client_by_id(client_id);
     if (client_obj == NULL) {
         crm_debug("Could not notify client %s%s %s of call %d result: "
                   "client no longer exists", client_id,
                   (from_peer? " (originator of delegated request)" : ""),
                   (sync_reply? "synchronously" : "asynchronously"), msg_id);
         return;
     }
 
     if (sync_reply) {
         flags = crm_ipc_flags_none;
         if (client_obj->ipcs != NULL) {
             msg_id = client_obj->request_id;
             client_obj->request_id = 0;
         }
     }
 
     switch (PCMK__CLIENT_TYPE(client_obj)) {
         case pcmk__client_ipc:
             rc = pcmk__ipc_send_xml(client_obj, msg_id, notify_src, flags);
             break;
         case pcmk__client_tls:
         case pcmk__client_tcp:
             rc = pcmk__remote_send_xml(client_obj->remote, notify_src);
             break;
         default:
             rc = EPROTONOSUPPORT;
             break;
     }
     if (rc == pcmk_rc_ok) {
         crm_trace("Notified %s client %s%s %s of call %d result",
                   pcmk__client_type_str(PCMK__CLIENT_TYPE(client_obj)),
                   pcmk__client_name(client_obj),
                   (from_peer? " (originator of delegated request)" : ""),
                   (sync_reply? "synchronously" : "asynchronously"), msg_id);
     } else {
         crm_warn("Could not notify %s client %s%s %s of call %d result: %s",
                  pcmk__client_type_str(PCMK__CLIENT_TYPE(client_obj)),
                  pcmk__client_name(client_obj),
                  (from_peer? " (originator of delegated request)" : ""),
                  (sync_reply? "synchronously" : "asynchronously"), msg_id,
                  pcmk_rc_str(rc));
     }
 }
 
 void
 cib_common_callback_worker(uint32_t id, uint32_t flags, xmlNode * op_request,
                            pcmk__client_t *cib_client, gboolean privileged)
 {
     const char *op = crm_element_value(op_request, PCMK__XA_CIB_OP);
     uint32_t call_options = cib_none;
     int rc = pcmk_rc_ok;
 
     rc = pcmk__xe_get_flags(op_request, PCMK__XA_CIB_CALLOPT, &call_options,
                             cib_none);
     if (rc != pcmk_rc_ok) {
         crm_warn("Couldn't parse options from request: %s", pcmk_rc_str(rc));
     }
 
     /* Requests with cib_transaction set should not be sent to based directly
      * (outside of a commit-transaction request)
      */
     if (pcmk_is_set(call_options, cib_transaction)) {
         return;
     }
 
     if (pcmk__str_eq(op, CRM_OP_REGISTER, pcmk__str_none)) {
         if (flags & crm_ipc_client_response) {
             xmlNode *ack = pcmk__xe_create(NULL, __func__);
 
             crm_xml_add(ack, PCMK__XA_CIB_OP, CRM_OP_REGISTER);
             crm_xml_add(ack, PCMK__XA_CIB_CLIENTID, cib_client->id);
             pcmk__ipc_send_xml(cib_client, id, ack, flags);
             cib_client->request_id = 0;
             pcmk__xml_free(ack);
         }
         return;
 
     } else if (pcmk__str_eq(op, PCMK__VALUE_CIB_NOTIFY, pcmk__str_none)) {
         /* Update the notify filters for this client */
         int on_off = 0;
         crm_exit_t status = CRM_EX_OK;
         uint64_t bit = UINT64_C(0);
         const char *type = crm_element_value(op_request,
                                              PCMK__XA_CIB_NOTIFY_TYPE);
 
         crm_element_value_int(op_request, PCMK__XA_CIB_NOTIFY_ACTIVATE,
                               &on_off);
 
         crm_debug("Setting %s callbacks %s for client %s",
                   type, (on_off? "on" : "off"), pcmk__client_name(cib_client));
 
         if (pcmk__str_eq(type, PCMK__VALUE_CIB_POST_NOTIFY, pcmk__str_none)) {
             bit = cib_notify_post;
 
         } else if (pcmk__str_eq(type, PCMK__VALUE_CIB_PRE_NOTIFY,
                                 pcmk__str_none)) {
             bit = cib_notify_pre;
 
         } else if (pcmk__str_eq(type, PCMK__VALUE_CIB_UPDATE_CONFIRMATION,
                                 pcmk__str_none)) {
             bit = cib_notify_confirm;
 
         } else if (pcmk__str_eq(type, PCMK__VALUE_CIB_DIFF_NOTIFY,
                                 pcmk__str_none)) {
             bit = cib_notify_diff;
 
         } else {
             status = CRM_EX_INVALID_PARAM;
         }
 
         if (bit != 0) {
             if (on_off) {
                 pcmk__set_client_flags(cib_client, bit);
             } else {
                 pcmk__clear_client_flags(cib_client, bit);
             }
         }
 
         pcmk__ipc_send_ack(cib_client, id, flags, PCMK__XE_ACK, NULL, status);
         return;
     }
 
     cib_process_request(op_request, privileged, cib_client);
 }
 
 int32_t
 cib_common_callback(qb_ipcs_connection_t * c, void *data, size_t size, gboolean privileged)
 {
     uint32_t id = 0;
     uint32_t flags = 0;
     uint32_t call_options = cib_none;
     pcmk__client_t *cib_client = pcmk__find_client(c);
     xmlNode *op_request = pcmk__client_data2xml(cib_client, data, &id, &flags);
 
     if (op_request) {
         int rc = pcmk_rc_ok;
 
         rc = pcmk__xe_get_flags(op_request, PCMK__XA_CIB_CALLOPT, &call_options,
                                 cib_none);
         if (rc != pcmk_rc_ok) {
             crm_warn("Couldn't parse options from request: %s",
                      pcmk_rc_str(rc));
         }
     }
 
     if (op_request == NULL) {
         crm_trace("Invalid message from %p", c);
         pcmk__ipc_send_ack(cib_client, id, flags, PCMK__XE_NACK, NULL,
                            CRM_EX_PROTOCOL);
         return 0;
 
     } else if(cib_client == NULL) {
         crm_trace("Invalid client %p", c);
         return 0;
     }
 
     if (pcmk_is_set(call_options, cib_sync_call)) {
         CRM_LOG_ASSERT(flags & crm_ipc_client_response);
         CRM_LOG_ASSERT(cib_client->request_id == 0);    /* This means the client has two synchronous events in-flight */
         cib_client->request_id = id;    /* Reply only to the last one */
     }
 
     if (cib_client->name == NULL) {
         const char *value = crm_element_value(op_request,
                                               PCMK__XA_CIB_CLIENTNAME);
 
         if (value == NULL) {
             cib_client->name = pcmk__itoa(cib_client->pid);
         } else {
             cib_client->name = pcmk__str_copy(value);
             if (pcmk__parse_server(value) != pcmk_ipc_unknown) {
                 pcmk__set_client_flags(cib_client, cib_is_daemon);
             }
         }
     }
 
     /* Allow cluster daemons more leeway before being evicted */
     if (pcmk_is_set(cib_client->flags, cib_is_daemon)) {
         const char *qmax = cib_config_lookup(PCMK_OPT_CLUSTER_IPC_LIMIT);
 
         pcmk__set_client_queue_max(cib_client, qmax);
     }
 
     crm_xml_add(op_request, PCMK__XA_CIB_CLIENTID, cib_client->id);
     crm_xml_add(op_request, PCMK__XA_CIB_CLIENTNAME, cib_client->name);
 
     CRM_LOG_ASSERT(cib_client->user != NULL);
     pcmk__update_acl_user(op_request, PCMK__XA_CIB_USER, cib_client->user);
 
     cib_common_callback_worker(id, flags, op_request, cib_client, privileged);
     pcmk__xml_free(op_request);
 
     return 0;
 }
 
 static uint64_t ping_seq = 0;
 static char *ping_digest = NULL;
 static bool ping_modified_since = FALSE;
 
 static gboolean
 cib_digester_cb(gpointer data)
 {
     if (based_is_primary) {
         char buffer[32];
         xmlNode *ping = pcmk__xe_create(NULL, PCMK__XE_PING);
 
         ping_seq++;
         free(ping_digest);
         ping_digest = NULL;
         ping_modified_since = FALSE;
         snprintf(buffer, 32, "%" PRIu64, ping_seq);
         crm_trace("Requesting peer digests (%s)", buffer);
 
         crm_xml_add(ping, PCMK__XA_T, PCMK__VALUE_CIB);
         crm_xml_add(ping, PCMK__XA_CIB_OP, CRM_OP_PING);
         crm_xml_add(ping, PCMK__XA_CIB_PING_ID, buffer);
 
         crm_xml_add(ping, PCMK_XA_CRM_FEATURE_SET, CRM_FEATURE_SET);
         pcmk__cluster_send_message(NULL, pcmk_ipc_based, ping);
 
         pcmk__xml_free(ping);
     }
     return FALSE;
 }
 
 static void
 process_ping_reply(xmlNode *reply) 
 {
     uint64_t seq = 0;
     const char *host = crm_element_value(reply, PCMK__XA_SRC);
 
     xmlNode *wrapper = pcmk__xe_first_child(reply, PCMK__XE_CIB_CALLDATA, NULL,
                                             NULL);
     xmlNode *pong = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
 
     const char *seq_s = crm_element_value(pong, PCMK__XA_CIB_PING_ID);
     const char *digest = crm_element_value(pong, PCMK__XA_DIGEST);
 
     if (seq_s == NULL) {
         crm_debug("Ignoring ping reply with no " PCMK__XA_CIB_PING_ID);
         return;
 
     } else {
         long long seq_ll;
         int rc = pcmk__scan_ll(seq_s, &seq_ll, 0LL);
 
         if (rc != pcmk_rc_ok) {
             crm_debug("Ignoring ping reply with invalid " PCMK__XA_CIB_PING_ID
                       " '%s': %s", seq_s, pcmk_rc_str(rc));
             return;
         }
         seq = (uint64_t) seq_ll;
     }
 
     if(digest == NULL) {
         crm_trace("Ignoring ping reply %s from %s with no digest", seq_s, host);
 
     } else if(seq != ping_seq) {
         crm_trace("Ignoring out of sequence ping reply %s from %s", seq_s, host);
 
     } else if(ping_modified_since) {
         crm_trace("Ignoring ping reply %s from %s: cib updated since", seq_s, host);
 
     } else {
         if(ping_digest == NULL) {
             crm_trace("Calculating new digest");
             ping_digest = pcmk__digest_xml(the_cib, true);
         }
 
         crm_trace("Processing ping reply %s from %s (%s)", seq_s, host, digest);
         if (!pcmk__str_eq(ping_digest, digest, pcmk__str_casei)) {
             xmlNode *wrapper = pcmk__xe_first_child(pong, PCMK__XE_CIB_CALLDATA,
                                                     NULL, NULL);
             xmlNode *remote_cib = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
 
             const char *admin_epoch_s = NULL;
             const char *epoch_s = NULL;
             const char *num_updates_s = NULL;
 
             if (remote_cib != NULL) {
                 admin_epoch_s = crm_element_value(remote_cib,
                                                   PCMK_XA_ADMIN_EPOCH);
                 epoch_s = crm_element_value(remote_cib, PCMK_XA_EPOCH);
                 num_updates_s = crm_element_value(remote_cib,
                                                   PCMK_XA_NUM_UPDATES);
             }
 
             crm_notice("Local CIB %s.%s.%s.%s differs from %s: %s.%s.%s.%s %p",
                        crm_element_value(the_cib, PCMK_XA_ADMIN_EPOCH),
                        crm_element_value(the_cib, PCMK_XA_EPOCH),
                        crm_element_value(the_cib, PCMK_XA_NUM_UPDATES),
                        ping_digest, host,
                        pcmk__s(admin_epoch_s, "_"),
                        pcmk__s(epoch_s, "_"),
                        pcmk__s(num_updates_s, "_"),
                        digest, remote_cib);
 
             if(remote_cib && remote_cib->children) {
                 // Additional debug
-                xml_calculate_changes(the_cib, remote_cib);
+                pcmk__xml_mark_changes(the_cib, remote_cib);
                 pcmk__log_xml_changes(LOG_INFO, remote_cib);
                 crm_trace("End of differences");
             }
 
             pcmk__xml_free(remote_cib);
             sync_our_cib(reply, FALSE);
         }
     }
 }
 
 static void
 parse_local_options(const pcmk__client_t *cib_client,
                     const cib__operation_t *operation,
                     const char *host, const char *op, gboolean *local_notify,
                     gboolean *needs_reply, gboolean *process,
                     gboolean *needs_forward)
 {
     // Process locally and notify local client
     *process = TRUE;
     *needs_reply = FALSE;
     *local_notify = TRUE;
     *needs_forward = FALSE;
 
     if (pcmk_is_set(operation->flags, cib__op_attr_local)) {
         /* Always process locally if cib__op_attr_local is set.
          *
          * @COMPAT: Currently host is ignored. At a compatibility break, throw
          * an error (from cib_process_request() or earlier) if host is not NULL or
          * OUR_NODENAME.
          */
         crm_trace("Processing always-local %s op from client %s",
                   op, pcmk__client_name(cib_client));
 
         if (!pcmk__str_eq(host, OUR_NODENAME,
                           pcmk__str_casei|pcmk__str_null_matches)) {
 
             crm_warn("Operation '%s' is always local but its target host is "
                      "set to '%s'",
                      op, host);
         }
         return;
     }
 
     if (pcmk_is_set(operation->flags, cib__op_attr_modifies)
         || !pcmk__str_eq(host, OUR_NODENAME,
                          pcmk__str_casei|pcmk__str_null_matches)) {
 
         // Forward modifying and non-local requests via cluster
         *process = FALSE;
         *needs_reply = FALSE;
         *local_notify = FALSE;
         *needs_forward = TRUE;
 
         crm_trace("%s op from %s needs to be forwarded to %s",
                   op, pcmk__client_name(cib_client),
                   pcmk__s(host, "all nodes"));
         return;
     }
 
     if (stand_alone) {
         crm_trace("Processing %s op from client %s (stand-alone)",
                   op, pcmk__client_name(cib_client));
 
     } else {
         crm_trace("Processing %saddressed %s op from client %s",
                   ((host != NULL)? "locally " : "un"),
                   op, pcmk__client_name(cib_client));
     }
 }
 
 static gboolean
 parse_peer_options(const cib__operation_t *operation, xmlNode *request,
                    gboolean *local_notify, gboolean *needs_reply,
                    gboolean *process)
 {
     /* TODO: What happens when an update comes in after node A
      * requests the CIB from node B, but before it gets the reply (and
      * sends out the replace operation)?
      *
      * (This may no longer be relevant since legacy mode was dropped; need to
      * trace code more closely to check.)
      */
     const char *host = NULL;
     const char *delegated = crm_element_value(request,
                                               PCMK__XA_CIB_DELEGATED_FROM);
     const char *op = crm_element_value(request, PCMK__XA_CIB_OP);
     const char *originator = crm_element_value(request, PCMK__XA_SRC);
     const char *reply_to = crm_element_value(request, PCMK__XA_CIB_ISREPLYTO);
 
     gboolean is_reply = pcmk__str_eq(reply_to, OUR_NODENAME, pcmk__str_casei);
 
     if (originator == NULL) { // Shouldn't be possible
         originator = "peer";
     }
 
     if (pcmk__str_eq(op, PCMK__CIB_REQUEST_REPLACE, pcmk__str_none)) {
         // sync_our_cib() sets PCMK__XA_CIB_ISREPLYTO
         if (reply_to) {
             delegated = reply_to;
         }
         goto skip_is_reply;
 
     } else if (pcmk__str_eq(op, PCMK__CIB_REQUEST_SYNC_TO_ALL,
                             pcmk__str_none)) {
         // Nothing to do
 
     } else if (is_reply && pcmk__str_eq(op, CRM_OP_PING, pcmk__str_casei)) {
         process_ping_reply(request);
         return FALSE;
 
     } else if (pcmk__str_eq(op, PCMK__CIB_REQUEST_UPGRADE, pcmk__str_none)) {
         /* Only the DC (node with the oldest software) should process
          * this operation if PCMK__XA_CIB_SCHEMA_MAX is unset.
          *
          * If the DC is happy it will then send out another
          * PCMK__CIB_REQUEST_UPGRADE which will tell all nodes to do the actual
          * upgrade.
          *
          * Except this time PCMK__XA_CIB_SCHEMA_MAX will be set which puts a
          * limit on how far newer nodes will go
          */
         const char *max = crm_element_value(request, PCMK__XA_CIB_SCHEMA_MAX);
         const char *upgrade_rc = crm_element_value(request,
                                                    PCMK__XA_CIB_UPGRADE_RC);
 
         crm_trace("Parsing upgrade %s for %s with max=%s and upgrade_rc=%s",
                   (is_reply? "reply" : "request"),
                   (based_is_primary? "primary" : "secondary"),
                   pcmk__s(max, "none"), pcmk__s(upgrade_rc, "none"));
 
         if (upgrade_rc != NULL) {
             // Our upgrade request was rejected by DC, notify clients of result
             crm_xml_add(request, PCMK__XA_CIB_RC, upgrade_rc);
 
         } else if ((max == NULL) && based_is_primary) {
             /* We are the DC, check if this upgrade is allowed */
             goto skip_is_reply;
 
         } else if(max) {
             /* Ok, go ahead and upgrade to 'max' */
             goto skip_is_reply;
 
         } else {
             // Ignore broadcast client requests when we're not primary
             return FALSE;
         }
 
     } else if (pcmk__xe_attr_is_true(request, PCMK__XA_CIB_UPDATE)) {
         crm_info("Detected legacy %s global update from %s", op, originator);
         send_sync_request(NULL);
         return FALSE;
 
     } else if (is_reply
                && pcmk_is_set(operation->flags, cib__op_attr_modifies)) {
         crm_trace("Ignoring legacy %s reply sent from %s to local clients", op, originator);
         return FALSE;
 
     } else if (pcmk__str_eq(op, PCMK__CIB_REQUEST_SHUTDOWN, pcmk__str_none)) {
         *local_notify = FALSE;
         if (reply_to == NULL) {
             *process = TRUE;
         } else { // Not possible?
             crm_debug("Ignoring shutdown request from %s because reply_to=%s",
                       originator, reply_to);
         }
         return *process;
     }
 
     if (is_reply) {
         crm_trace("Will notify local clients for %s reply from %s",
                   op, originator);
         *process = FALSE;
         *needs_reply = FALSE;
         *local_notify = TRUE;
         return TRUE;
     }
 
   skip_is_reply:
     *process = TRUE;
     *needs_reply = FALSE;
 
     *local_notify = pcmk__str_eq(delegated, OUR_NODENAME, pcmk__str_casei);
 
     host = crm_element_value(request, PCMK__XA_CIB_HOST);
     if (pcmk__str_eq(host, OUR_NODENAME, pcmk__str_casei)) {
         crm_trace("Processing %s request sent to us from %s", op, originator);
         *needs_reply = TRUE;
         return TRUE;
 
     } else if (host != NULL) {
         crm_trace("Ignoring %s request intended for CIB manager on %s",
                   op, host);
         return FALSE;
 
     } else if(is_reply == FALSE && pcmk__str_eq(op, CRM_OP_PING, pcmk__str_casei)) {
         *needs_reply = TRUE;
     }
 
     crm_trace("Processing %s request broadcast by %s call %s on %s "
               "(local clients will%s be notified)", op,
               pcmk__s(crm_element_value(request, PCMK__XA_CIB_CLIENTNAME),
                       "client"),
               pcmk__s(crm_element_value(request, PCMK__XA_CIB_CALLID),
                       "without ID"),
               originator, (*local_notify? "" : "not"));
     return TRUE;
 }
 
 /*!
  * \internal
  * \brief Forward a CIB request to the appropriate target host(s)
  *
  * \param[in] request  CIB request to forward
  */
 static void
 forward_request(xmlNode *request)
 {
     const char *op = crm_element_value(request, PCMK__XA_CIB_OP);
     const char *section = crm_element_value(request, PCMK__XA_CIB_SECTION);
     const char *host = crm_element_value(request, PCMK__XA_CIB_HOST);
     const char *originator = crm_element_value(request, PCMK__XA_SRC);
     const char *client_name = crm_element_value(request,
                                                 PCMK__XA_CIB_CLIENTNAME);
     const char *call_id = crm_element_value(request, PCMK__XA_CIB_CALLID);
     pcmk__node_status_t *peer = NULL;
 
     int log_level = LOG_INFO;
 
     if (pcmk__str_eq(op, PCMK__CIB_REQUEST_NOOP, pcmk__str_none)) {
         log_level = LOG_DEBUG;
     }
 
     do_crm_log(log_level,
                "Forwarding %s operation for section %s to %s (origin=%s/%s/%s)",
                pcmk__s(op, "invalid"),
                pcmk__s(section, "all"),
                pcmk__s(host, "all"),
                pcmk__s(originator, "local"),
                pcmk__s(client_name, "unspecified"),
                pcmk__s(call_id, "unspecified"));
 
     crm_xml_add(request, PCMK__XA_CIB_DELEGATED_FROM, OUR_NODENAME);
 
     if (host != NULL) {
         peer = pcmk__get_node(0, host, NULL, pcmk__node_search_cluster_member);
     }
     pcmk__cluster_send_message(peer, pcmk_ipc_based, request);
 
     // Return the request to its original state
     pcmk__xe_remove_attr(request, PCMK__XA_CIB_DELEGATED_FROM);
 }
 
 static void
 send_peer_reply(xmlNode *msg, const char *originator)
 {
     const pcmk__node_status_t *node = NULL;
 
     if ((msg == NULL) || (originator == NULL)) {
         return;
     }
 
     // Send reply via cluster to originating node
     node = pcmk__get_node(0, originator, NULL,
                           pcmk__node_search_cluster_member);
 
     crm_trace("Sending request result to %s only", originator);
     crm_xml_add(msg, PCMK__XA_CIB_ISREPLYTO, originator);
     pcmk__cluster_send_message(node, pcmk_ipc_based, msg);
 }
 
 /*!
  * \internal
  * \brief Handle an IPC or CPG message containing a request
  *
  * \param[in,out] request        Request XML
  * \param[in] privileged         Whether privileged commands may be run
  *                               (see cib_server_ops[] definition)
  * \param[in] cib_client         IPC client that sent request (or NULL if CPG)
  *
  * \return Legacy Pacemaker return code
  */
 int
 cib_process_request(xmlNode *request, gboolean privileged,
                     const pcmk__client_t *cib_client)
 {
     // @TODO: Break into multiple smaller functions
     uint32_t call_options = cib_none;
 
     gboolean process = TRUE;        // Whether to process request locally now
     gboolean is_update = TRUE;      // Whether request would modify CIB
     gboolean needs_reply = TRUE;    // Whether to build a reply
     gboolean local_notify = FALSE;  // Whether to notify (local) requester
     gboolean needs_forward = FALSE; // Whether to forward request somewhere else
 
     xmlNode *op_reply = NULL;
     xmlNode *result_diff = NULL;
 
     int rc = pcmk_ok;
     const char *op = crm_element_value(request, PCMK__XA_CIB_OP);
     const char *originator = crm_element_value(request, PCMK__XA_SRC);
     const char *host = crm_element_value(request, PCMK__XA_CIB_HOST);
     const char *call_id = crm_element_value(request, PCMK__XA_CIB_CALLID);
     const char *client_id = crm_element_value(request, PCMK__XA_CIB_CLIENTID);
     const char *client_name = crm_element_value(request,
                                                 PCMK__XA_CIB_CLIENTNAME);
     const char *reply_to = crm_element_value(request, PCMK__XA_CIB_ISREPLYTO);
 
     const cib__operation_t *operation = NULL;
     cib__op_fn_t op_function = NULL;
 
     rc = pcmk__xe_get_flags(request, PCMK__XA_CIB_CALLOPT, &call_options,
                             cib_none);
     if (rc != pcmk_rc_ok) {
         crm_warn("Couldn't parse options from request: %s", pcmk_rc_str(rc));
     }
 
     if ((host != NULL) && (*host == '\0')) {
         host = NULL;
     }
 
     if (cib_client == NULL) {
         crm_trace("Processing peer %s operation from %s/%s on %s intended for %s (reply=%s)",
                   op, pcmk__s(client_name, "client"), call_id, originator,
                   pcmk__s(host, "all"), reply_to);
     } else {
         crm_xml_add(request, PCMK__XA_SRC, OUR_NODENAME);
         crm_trace("Processing local %s operation from %s/%s intended for %s",
                   op, pcmk__s(client_name, "client"), call_id,
                   pcmk__s(host, "all"));
     }
 
     rc = cib__get_operation(op, &operation);
     rc = pcmk_rc2legacy(rc);
     if (rc != pcmk_ok) {
         /* TODO: construct error reply? */
         crm_err("Pre-processing of command failed: %s", pcmk_strerror(rc));
         return rc;
     }
 
     op_function = based_get_op_function(operation);
     if (op_function == NULL) {
         crm_err("Operation %s not supported by CIB manager", op);
         return -EOPNOTSUPP;
     }
 
     if (cib_client != NULL) {
         parse_local_options(cib_client, operation, host, op,
                             &local_notify, &needs_reply, &process,
                             &needs_forward);
 
     } else if (!parse_peer_options(operation, request, &local_notify,
                                    &needs_reply, &process)) {
         return rc;
     }
 
     if (pcmk_is_set(call_options, cib_transaction)) {
         /* All requests in a transaction are processed locally against a working
          * CIB copy, and we don't notify for individual requests because the
          * entire transaction is atomic.
          *
          * We still call the option parser functions above, for the sake of log
          * messages and checking whether we're the target for peer requests.
          */
         process = TRUE;
         needs_reply = FALSE;
         local_notify = FALSE;
         needs_forward = FALSE;
     }
 
     is_update = pcmk_is_set(operation->flags, cib__op_attr_modifies);
 
     if (pcmk_is_set(call_options, cib_discard_reply)) {
         /* If the request will modify the CIB, and we are in legacy mode, we
          * need to build a reply so we can broadcast a diff, even if the
          * requester doesn't want one.
          */
         needs_reply = FALSE;
         local_notify = FALSE;
         crm_trace("Client is not interested in the reply");
     }
 
     if (needs_forward) {
         forward_request(request);
         return rc;
     }
 
     if (cib_status != pcmk_ok) {
         rc = cib_status;
         crm_err("Ignoring request because cluster configuration is invalid "
                 "(please repair and restart): %s", pcmk_strerror(rc));
         op_reply = create_cib_reply(op, call_id, client_id, call_options, rc,
                                     the_cib);
 
     } else if (process) {
         time_t finished = 0;
         time_t now = time(NULL);
         int level = LOG_INFO;
         const char *section = crm_element_value(request, PCMK__XA_CIB_SECTION);
         const char *admin_epoch_s = NULL;
         const char *epoch_s = NULL;
         const char *num_updates_s = NULL;
 
         rc = cib_process_command(request, operation, op_function, &op_reply,
                                  &result_diff, privileged);
 
         if (!is_update) {
             level = LOG_TRACE;
 
         } else if (pcmk__xe_attr_is_true(request, PCMK__XA_CIB_UPDATE)) {
             switch (rc) {
                 case pcmk_ok:
                     level = LOG_INFO;
                     break;
                 case -pcmk_err_old_data:
                 case -pcmk_err_diff_resync:
                 case -pcmk_err_diff_failed:
                     level = LOG_TRACE;
                     break;
                 default:
                     level = LOG_ERR;
             }
 
         } else if (rc != pcmk_ok) {
             level = LOG_WARNING;
         }
 
         if (the_cib != NULL) {
             admin_epoch_s = crm_element_value(the_cib, PCMK_XA_ADMIN_EPOCH);
             epoch_s = crm_element_value(the_cib, PCMK_XA_EPOCH);
             num_updates_s = crm_element_value(the_cib, PCMK_XA_NUM_UPDATES);
         }
 
         do_crm_log(level,
                    "Completed %s operation for section %s: %s (rc=%d, origin=%s/%s/%s, version=%s.%s.%s)",
                    op, section ? section : "'all'", pcmk_strerror(rc), rc,
                    originator ? originator : "local",
                    pcmk__s(client_name, "client"), call_id,
                    pcmk__s(admin_epoch_s, "0"),
                    pcmk__s(epoch_s, "0"),
                    pcmk__s(num_updates_s, "0"));
 
         finished = time(NULL);
         if ((finished - now) > 3) {
             crm_trace("%s operation took %lds to complete", op, (long)(finished - now));
             crm_write_blackbox(0, NULL);
         }
 
         if (op_reply == NULL && (needs_reply || local_notify)) {
             crm_err("Unexpected NULL reply to message");
             crm_log_xml_err(request, "null reply");
             needs_reply = FALSE;
             local_notify = FALSE;
         }
     }
 
     if (is_update) {
         crm_trace("Completed pre-sync update from %s/%s/%s%s",
                   originator ? originator : "local",
                   pcmk__s(client_name, "client"), call_id,
                   local_notify?" with local notification":"");
 
     } else if (!needs_reply || stand_alone) {
         // This was a non-originating secondary update
         crm_trace("Completed update as secondary");
 
     } else if ((cib_client == NULL)
                && !pcmk_is_set(call_options, cib_discard_reply)) {
 
         if (is_update == FALSE || result_diff == NULL) {
             crm_trace("Request not broadcast: R/O call");
 
         } else if (rc != pcmk_ok) {
             crm_trace("Request not broadcast: call failed: %s", pcmk_strerror(rc));
 
         } else {
             crm_trace("Directing reply to %s", originator);
         }
 
         send_peer_reply(op_reply, originator);
     }
 
     if (local_notify && client_id) {
         crm_trace("Performing local %ssync notification for %s",
                   (pcmk_is_set(call_options, cib_sync_call)? "" : "a"),
                   client_id);
         if (process == FALSE) {
             do_local_notify(request, client_id,
                             pcmk_is_set(call_options, cib_sync_call),
                             (cib_client == NULL));
         } else {
             do_local_notify(op_reply, client_id,
                             pcmk_is_set(call_options, cib_sync_call),
                             (cib_client == NULL));
         }
     }
 
     pcmk__xml_free(op_reply);
     pcmk__xml_free(result_diff);
 
     return rc;
 }
 
 /*!
  * \internal
  * \brief Get a CIB operation's input from the request XML
  *
  * \param[in]  request  CIB request XML
  * \param[in]  type     CIB operation type
  * \param[out] section  Where to store CIB section name
  *
  * \return Input XML for CIB operation
  *
  * \note If not \c NULL, the return value is a non-const pointer to part of
  *       \p request. The caller should not free it directly.
  */
 static xmlNode *
 prepare_input(const xmlNode *request, enum cib__op_type type,
               const char **section)
 {
     xmlNode *wrapper = pcmk__xe_first_child(request, PCMK__XE_CIB_CALLDATA,
                                             NULL, NULL);
     xmlNode *input = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
 
     if (type == cib__op_apply_patch) {
         *section = NULL;
     } else {
         *section = crm_element_value(request, PCMK__XA_CIB_SECTION);
     }
 
     // Grab the specified section
     if ((*section != NULL) && pcmk__xe_is(input, PCMK_XE_CIB)) {
         input = pcmk_find_cib_element(input, *section);
     }
 
     return input;
 }
 
 #define XPATH_CONFIG_CHANGE         \
     "//" PCMK_XE_CHANGE             \
     "[contains(@" PCMK_XA_PATH ",'/" PCMK_XE_CRM_CONFIG "/')]"
 
 static bool
 contains_config_change(xmlNode *diff)
 {
     bool changed = false;
 
     if (diff) {
         xmlXPathObject *xpathObj = pcmk__xpath_search(diff->doc,
                                                       XPATH_CONFIG_CHANGE);
 
         if (pcmk__xpath_num_results(xpathObj) > 0) {
             changed = true;
         }
         xmlXPathFreeObject(xpathObj);
     }
     return changed;
 }
 
 static int
 cib_process_command(xmlNode *request, const cib__operation_t *operation,
                     cib__op_fn_t op_function, xmlNode **reply,
                     xmlNode **cib_diff, bool privileged)
 {
     xmlNode *input = NULL;
     xmlNode *output = NULL;
     xmlNode *result_cib = NULL;
 
     uint32_t call_options = cib_none;
 
     const char *op = NULL;
     const char *section = NULL;
     const char *call_id = crm_element_value(request, PCMK__XA_CIB_CALLID);
     const char *client_id = crm_element_value(request, PCMK__XA_CIB_CLIENTID);
     const char *client_name = crm_element_value(request,
                                                 PCMK__XA_CIB_CLIENTNAME);
     const char *originator = crm_element_value(request, PCMK__XA_SRC);
 
     int rc = pcmk_ok;
 
     bool config_changed = false;
     bool manage_counters = true;
 
     static mainloop_timer_t *digest_timer = NULL;
 
     pcmk__assert(cib_status == pcmk_ok);
 
     if(digest_timer == NULL) {
         digest_timer = mainloop_timer_add("digester", 5000, FALSE, cib_digester_cb, NULL);
     }
 
     *reply = NULL;
     *cib_diff = NULL;
 
     /* Start processing the request... */
     op = crm_element_value(request, PCMK__XA_CIB_OP);
     rc = pcmk__xe_get_flags(request, PCMK__XA_CIB_CALLOPT, &call_options,
                             cib_none);
     if (rc != pcmk_rc_ok) {
         crm_warn("Couldn't parse options from request: %s", pcmk_rc_str(rc));
     }
 
     if (!privileged && pcmk_is_set(operation->flags, cib__op_attr_privileged)) {
         rc = -EACCES;
         crm_trace("Failed due to lack of privileges: %s", pcmk_strerror(rc));
         goto done;
     }
 
     input = prepare_input(request, operation->type, &section);
 
     if (!pcmk_is_set(operation->flags, cib__op_attr_modifies)) {
         rc = cib_perform_op(NULL, op, call_options, op_function, true, section,
                             request, input, false, &config_changed, &the_cib,
                             &result_cib, NULL, &output);
 
         CRM_CHECK(result_cib == NULL, pcmk__xml_free(result_cib));
         goto done;
     }
 
     /* @COMPAT: Handle a valid write action (legacy)
      *
      * @TODO: Re-evaluate whether this is all truly legacy. The cib_force_diff
      * portion is. However, PCMK__XA_CIB_UPDATE may be set by a sync operation
      * even in non-legacy mode, and manage_counters tells xml_create_patchset()
      * whether to update version/epoch info.
      */
     if (pcmk__xe_attr_is_true(request, PCMK__XA_CIB_UPDATE)) {
         manage_counters = false;
         cib__set_call_options(call_options, "call", cib_force_diff);
         crm_trace("Global update detected");
 
         CRM_LOG_ASSERT(pcmk__str_any_of(op,
                                         PCMK__CIB_REQUEST_APPLY_PATCH,
                                         PCMK__CIB_REQUEST_REPLACE,
                                         NULL));
     }
 
     ping_modified_since = TRUE;
 
     // result_cib must not be modified after cib_perform_op() returns
     rc = cib_perform_op(NULL, op, call_options, op_function, false, section,
                         request, input, manage_counters, &config_changed,
                         &the_cib, &result_cib, cib_diff, &output);
 
     /* Always write to disk for successful ops with the flag set. This also
      * negates the need to detect ordering changes.
      */
     if ((rc == pcmk_ok)
         && pcmk_is_set(operation->flags, cib__op_attr_writes_through)) {
 
         config_changed = true;
     }
 
     if ((rc == pcmk_ok)
         && !pcmk_any_flags_set(call_options, cib_dryrun|cib_transaction)) {
 
         if (result_cib != the_cib) {
             if (pcmk_is_set(operation->flags, cib__op_attr_writes_through)) {
                 config_changed = true;
             }
 
             crm_trace("Activating %s->%s%s",
                       crm_element_value(the_cib, PCMK_XA_NUM_UPDATES),
                       crm_element_value(result_cib, PCMK_XA_NUM_UPDATES),
                       (config_changed? " changed" : ""));
 
             rc = activateCibXml(result_cib, config_changed, op);
             if (rc != pcmk_ok) {
                 crm_err("Failed to activate new CIB: %s", pcmk_strerror(rc));
             }
         }
 
         if ((rc == pcmk_ok) && contains_config_change(*cib_diff)) {
             cib_read_config(config_hash, result_cib);
         }
 
         /* @COMPAT Nodes older than feature set 3.19.0 don't support
          * transactions. In a mixed-version cluster with nodes <3.19.0, we must
          * sync the updated CIB, so that the older nodes receive the changes.
          * Any node that has already applied the transaction will ignore the
          * synced CIB.
          *
          * To ensure the updated CIB is synced from only one node, we sync it
          * from the originator.
          */
         if ((operation->type == cib__op_commit_transact)
             && pcmk__str_eq(originator, OUR_NODENAME, pcmk__str_casei)
             && compare_version(crm_element_value(the_cib,
                                                  PCMK_XA_CRM_FEATURE_SET),
                                "3.19.0") < 0) {
 
             sync_our_cib(request, TRUE);
         }
 
         mainloop_timer_stop(digest_timer);
         mainloop_timer_start(digest_timer);
 
     } else if (rc == -pcmk_err_schema_validation) {
         pcmk__assert(result_cib != the_cib);
 
         if (output != NULL) {
             crm_log_xml_info(output, "cib:output");
             pcmk__xml_free(output);
         }
 
         output = result_cib;
 
     } else {
         crm_trace("Not activating %d %d %s", rc,
                   pcmk_is_set(call_options, cib_dryrun),
                   crm_element_value(result_cib, PCMK_XA_NUM_UPDATES));
 
         if (result_cib != the_cib) {
             pcmk__xml_free(result_cib);
         }
     }
 
     if (!pcmk_any_flags_set(call_options,
                             cib_dryrun|cib_inhibit_notify|cib_transaction)) {
         crm_trace("Sending notifications %d",
                   pcmk_is_set(call_options, cib_dryrun));
         cib_diff_notify(op, rc, call_id, client_id, client_name, originator,
                         input, *cib_diff);
     }
 
     pcmk__log_xml_patchset(LOG_TRACE, *cib_diff);
 
   done:
     if (!pcmk_is_set(call_options, cib_discard_reply)) {
         *reply = create_cib_reply(op, call_id, client_id, call_options, rc,
                                   output);
     }
 
     if (output != the_cib) {
         pcmk__xml_free(output);
     }
     crm_trace("done");
     return rc;
 }
 
 void
 cib_peer_callback(xmlNode * msg, void *private_data)
 {
     const char *reason = NULL;
     const char *originator = crm_element_value(msg, PCMK__XA_SRC);
 
     if (pcmk__peer_cache == NULL) {
         reason = "membership not established";
         goto bail;
     }
 
     if (crm_element_value(msg, PCMK__XA_CIB_CLIENTNAME) == NULL) {
         crm_xml_add(msg, PCMK__XA_CIB_CLIENTNAME, originator);
     }
 
     /* crm_log_xml_trace(msg, "Peer[inbound]"); */
     cib_process_request(msg, TRUE, NULL);
     return;
 
   bail:
     if (reason) {
         const char *op = crm_element_value(msg, PCMK__XA_CIB_OP);
 
         crm_warn("Discarding %s message from %s: %s", op, originator, reason);
     }
 }
 
 static gboolean
 cib_force_exit(gpointer data)
 {
     crm_notice("Exiting immediately after %s without shutdown acknowledgment",
                pcmk__readable_interval(EXIT_ESCALATION_MS));
     terminate_cib(CRM_EX_ERROR);
     return FALSE;
 }
 
 static void
 disconnect_remote_client(gpointer key, gpointer value, gpointer user_data)
 {
     pcmk__client_t *a_client = value;
 
     crm_err("Can't disconnect client %s: Not implemented",
             pcmk__client_name(a_client));
 }
 
 static void
 initiate_exit(void)
 {
     int active = 0;
     xmlNode *leaving = NULL;
 
     active = pcmk__cluster_num_active_nodes();
     if (active < 2) { // This is the last active node
         crm_info("Exiting without sending shutdown request (no active peers)");
         terminate_cib(CRM_EX_OK);
         return;
     }
 
     crm_info("Sending shutdown request to %d peers", active);
 
     leaving = pcmk__xe_create(NULL, PCMK__XE_EXIT_NOTIFICATION);
     crm_xml_add(leaving, PCMK__XA_T, PCMK__VALUE_CIB);
     crm_xml_add(leaving, PCMK__XA_CIB_OP, PCMK__CIB_REQUEST_SHUTDOWN);
 
     pcmk__cluster_send_message(NULL, pcmk_ipc_based, leaving);
     pcmk__xml_free(leaving);
 
     pcmk__create_timer(EXIT_ESCALATION_MS, cib_force_exit, NULL);
 }
 
 void
 cib_shutdown(int nsig)
 {
     struct qb_ipcs_stats srv_stats;
 
     if (cib_shutdown_flag == FALSE) {
         int disconnects = 0;
         qb_ipcs_connection_t *c = NULL;
 
         cib_shutdown_flag = TRUE;
 
         c = qb_ipcs_connection_first_get(ipcs_rw);
         while (c != NULL) {
             qb_ipcs_connection_t *last = c;
 
             c = qb_ipcs_connection_next_get(ipcs_rw, last);
 
             crm_debug("Disconnecting r/w client %p...", last);
             qb_ipcs_disconnect(last);
             qb_ipcs_connection_unref(last);
             disconnects++;
         }
 
         c = qb_ipcs_connection_first_get(ipcs_ro);
         while (c != NULL) {
             qb_ipcs_connection_t *last = c;
 
             c = qb_ipcs_connection_next_get(ipcs_ro, last);
 
             crm_debug("Disconnecting r/o client %p...", last);
             qb_ipcs_disconnect(last);
             qb_ipcs_connection_unref(last);
             disconnects++;
         }
 
         c = qb_ipcs_connection_first_get(ipcs_shm);
         while (c != NULL) {
             qb_ipcs_connection_t *last = c;
 
             c = qb_ipcs_connection_next_get(ipcs_shm, last);
 
             crm_debug("Disconnecting non-blocking r/w client %p...", last);
             qb_ipcs_disconnect(last);
             qb_ipcs_connection_unref(last);
             disconnects++;
         }
 
         disconnects += pcmk__ipc_client_count();
 
         crm_debug("Disconnecting %d remote clients", pcmk__ipc_client_count());
         pcmk__foreach_ipc_client(disconnect_remote_client, NULL);
         crm_info("Disconnected %d clients", disconnects);
     }
 
     qb_ipcs_stats_get(ipcs_rw, &srv_stats, QB_FALSE);
 
     if (pcmk__ipc_client_count() == 0) {
         crm_info("All clients disconnected (%d)", srv_stats.active_connections);
         initiate_exit();
 
     } else {
         crm_info("Waiting on %d clients to disconnect (%d)",
                  pcmk__ipc_client_count(), srv_stats.active_connections);
     }
 }
 
 extern int remote_fd;
 extern int remote_tls_fd;
 
 /*!
  * \internal
  * \brief Close remote sockets, free the global CIB and quit
  *
  * \param[in] exit_status  What exit status to use (if -1, use CRM_EX_OK, but
  *                         skip disconnecting from the cluster layer)
  */
 void
 terminate_cib(int exit_status)
 {
     if (remote_fd > 0) {
         close(remote_fd);
         remote_fd = 0;
     }
     if (remote_tls_fd > 0) {
         close(remote_tls_fd);
         remote_tls_fd = 0;
     }
 
     uninitializeCib();
 
     // Exit immediately on error
     if (exit_status > CRM_EX_OK) {
         pcmk__stop_based_ipc(ipcs_ro, ipcs_rw, ipcs_shm);
         crm_exit(exit_status);
         return;
     }
 
     if ((mainloop != NULL) && g_main_loop_is_running(mainloop)) {
         /* Quit via returning from the main loop. If exit_status has the special
          * value -1, we skip the disconnect here, and it will be done when the
          * main loop returns (this allows the peer status callback to avoid
          * messing with the peer caches).
          */
         if (exit_status == CRM_EX_OK) {
             pcmk_cluster_disconnect(crm_cluster);
         }
         g_main_loop_quit(mainloop);
         return;
     }
 
     /* Exit cleanly. Even the peer status callback can disconnect here, because
      * we're not returning control to the caller.
      */
     pcmk_cluster_disconnect(crm_cluster);
     pcmk__stop_based_ipc(ipcs_ro, ipcs_rw, ipcs_shm);
     crm_exit(CRM_EX_OK);
 }
diff --git a/include/crm/common/acl_internal.h b/include/crm/common/acl_internal.h
index a65be93c58..38d7f21e95 100644
--- a/include/crm/common/acl_internal.h
+++ b/include/crm/common/acl_internal.h
@@ -1,43 +1,43 @@
 /*
  * Copyright 2015-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_ACL_INTERNAL__H
 #define PCMK__CRM_COMMON_ACL_INTERNAL__H
 
 #include <string.h>         // strcmp()
 #include <libxml/tree.h>    // xmlNode
 
-#include <crm/common/xml_internal.h>    // enum xml_private_flags
+#include <crm/common/xml_internal.h>    // enum pcmk__xml_flags
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /* internal ACL-related utilities */
 
 char *pcmk__uid2username(uid_t uid);
 const char *pcmk__update_acl_user(xmlNode *request, const char *field,
                                   const char *peer_user);
 
 static inline bool
 pcmk__is_privileged(const char *user)
 {
     return user && (!strcmp(user, CRM_DAEMON_USER) || !strcmp(user, "root"));
 }
 
 void pcmk__enable_acl(xmlNode *acl_source, xmlNode *target, const char *user);
 
 bool pcmk__check_acl(xmlNode *xml, const char *attr_name,
-                     enum xml_private_flags mode);
+                     enum pcmk__xml_flags mode);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_INTERNAL__H
diff --git a/include/crm/common/xml.h b/include/crm/common/xml.h
index ac4c073926..1305ddb541 100644
--- a/include/crm/common/xml.h
+++ b/include/crm/common/xml.h
@@ -1,56 +1,52 @@
 /*
  * Copyright 2004-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML__H
 #define PCMK__CRM_COMMON_XML__H
 
 #include <stdbool.h>                // bool
 
 #include <libxml/tree.h>            // xmlNode
 
 // xml.h is a wrapper for the following headers
 #include <crm/common/xml_element.h>
 #include <crm/common/xml_io.h>
 #include <crm/common/xml_names.h>
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /**
  * \file
  * \brief Wrappers for and extensions to libxml2
  * \ingroup core
  */
 
 /*
  * Searching & Modifying
  */
 
-void xml_track_changes(xmlNode * xml, const char *user, xmlNode *acl_source, bool enforce_acls);
-void xml_calculate_changes(xmlNode *old_xml, xmlNode *new_xml);
-void xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml);
-void xml_accept_changes(xmlNode * xml);
 bool xml_patch_versions(const xmlNode *patchset, int add[3], int del[3]);
 
 xmlNode *xml_create_patchset(
     int format, xmlNode *source, xmlNode *target, bool *config, bool manage_version);
 int xml_apply_patchset(xmlNode *xml, xmlNode *patchset, bool check_version);
 
 void patchset_process_digest(xmlNode *patch, xmlNode *source, xmlNode *target, bool with_digest);
 
 #ifdef __cplusplus
 }
 #endif
 
 #if !defined(PCMK_ALLOW_DEPRECATED) || (PCMK_ALLOW_DEPRECATED == 1)
 #include <crm/common/xml_compat.h>
 #endif
 
 #endif
diff --git a/include/crm/common/xml_compat.h b/include/crm/common/xml_compat.h
index d575326e0a..dc3f924757 100644
--- a/include/crm/common/xml_compat.h
+++ b/include/crm/common/xml_compat.h
@@ -1,118 +1,131 @@
 /*
  * Copyright 2004-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML_COMPAT__H
 #define PCMK__CRM_COMMON_XML_COMPAT__H
 
 #include <glib.h>               // gboolean
 #include <libxml/tree.h>        // xmlNode
 #include <libxml/xpath.h>           // xmlXPathObject
 
 #include <crm/common/nvpair.h>  // crm_xml_add()
 #include <crm/common/xml_names.h>   // PCMK_XE_CLONE
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /**
  * \file
  * \brief Deprecated Pacemaker XML API
  * \ingroup core
  * \deprecated Do not include this header directly. The XML APIs in this
  *             header, and the header itself, will be removed in a future
  *             release.
  */
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Use name member directly
 static inline const char *
 crm_element_name(const xmlNode *xml)
 {
     return (xml == NULL)? NULL : (const char *) xml->name;
 }
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Do not use Pacemaker for general-purpose XML manipulation
 xmlNode *copy_xml(xmlNode *src_node);
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Do not use
 gboolean cli_config_update(xmlNode **xml, int *best_version, gboolean to_logs);
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Call \c crm_log_init() or \c crm_log_cli_init() instead
 void crm_xml_init(void);
 
 //! \deprecated Exit with \c crm_exit() instead
 void crm_xml_cleanup(void);
 
 //! \deprecated Do not use Pacemaker for general-purpose XML manipulation
 void pcmk_free_xml_subtree(xmlNode *xml);
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Do not use Pacemaker for general-purpose XML manipulation
 void free_xml(xmlNode *child);
 
 //! \deprecated Do not use Pacemaker for general-purpose XML manipulation
 void crm_xml_sanitize_id(char *id);
 
 //! \deprecated Do not use
 char *calculate_on_disk_digest(xmlNode *input);
 
 //! \deprecated Do not use
 char *calculate_operation_digest(xmlNode *input, const char *version);
 
 //! \deprecated Do not use
 char *calculate_xml_versioned_digest(xmlNode *input, gboolean sort,
                                      gboolean do_filter, const char *version);
 
 //! \deprecated Do not use
 xmlXPathObjectPtr xpath_search(const xmlNode *xml_top, const char *path);
 
 //! \deprecated Do not use
 static inline int numXpathResults(xmlXPathObjectPtr xpathObj)
 {
     if ((xpathObj == NULL) || (xpathObj->nodesetval == NULL)) {
         return 0;
     }
     return xpathObj->nodesetval->nodeNr;
 }
 
 //! \deprecated Do not use
 xmlNode *getXpathResult(xmlXPathObjectPtr xpathObj, int index);
 
 //! \deprecated Do not use
 void freeXpathObject(xmlXPathObjectPtr xpathObj);
 
 //! \deprecated Do not use
 void dedupXpathResults(xmlXPathObjectPtr xpathObj);
 
 //! \deprecated Do not use
 void crm_foreach_xpath_result(xmlNode *xml, const char *xpath,
                               void (*helper)(xmlNode*, void*), void *user_data);
 
 // NOTE: sbd (as of at least 1.5.2) uses this
 //! \deprecated Do not use
 xmlNode *get_xpath_object(const char *xpath, xmlNode *xml_obj, int error_level);
 
 //! \deprecated Do not use
 typedef const xmlChar *pcmkXmlStr;
 
 //! \deprecated Do not use
 bool xml_tracking_changes(xmlNode *xml);
 
 //! \deprecated Do not use
 bool xml_document_dirty(xmlNode *xml);
 
+//! \deprecated Do not use
+void xml_accept_changes(xmlNode *xml);
+
+//! \deprecated Do not use
+void xml_track_changes(xmlNode *xml, const char *user, xmlNode *acl_source,
+                       bool enforce_acls);
+
+//! \deprecated Do not use
+void xml_calculate_changes(xmlNode *old_xml, xmlNode *new_xml);
+
+//! \deprecated Do not use
+void xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml);
+
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_XML_COMPAT__H
diff --git a/include/crm/common/xml_internal.h b/include/crm/common/xml_internal.h
index 4245985a74..b572155e9c 100644
--- a/include/crm/common/xml_internal.h
+++ b/include/crm/common/xml_internal.h
@@ -1,403 +1,457 @@
 /*
  * Copyright 2017-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML_INTERNAL__H
 #define PCMK__CRM_COMMON_XML_INTERNAL__H
 
 /*
  * Internal-only wrappers for and extensions to libxml2 (libxslt)
  */
 
 #include <stdlib.h>
 #include <stdint.h>   // uint32_t
 #include <stdio.h>
 
 #include <crm/crm.h>  /* transitively imports qblog.h */
 #include <crm/common/output_internal.h>
 #include <crm/common/xml_names.h>             // PCMK_XA_ID, PCMK_XE_CLONE
 
 // This file is a wrapper for other {xml_*,xpath}_internal.h headers
 #include <crm/common/xml_comment_internal.h>
 #include <crm/common/xml_element_internal.h>
 #include <crm/common/xml_idref_internal.h>
 #include <crm/common/xml_io_internal.h>
 #include <crm/common/xml_names_internal.h>
 #include <crm/common/xpath_internal.h>
 
 #include <libxml/relaxng.h>
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /*!
  * \brief Base for directing lib{xml2,xslt} log into standard libqb backend
  *
  * This macro implements the core of what can be needed for directing
  * libxml2 or libxslt error messaging into standard, preconfigured
  * libqb-backed log stream.
  *
  * It's a bit unfortunate that libxml2 (and more sparsely, also libxslt)
  * emits a single message by chunks (location is emitted separatedly from
  * the message itself), so we have to take the effort to combine these
  * chunks back to single message.  Whether to do this or not is driven
  * with \p dechunk toggle.
  *
  * The form of a macro was chosen for implicit deriving of __FILE__, etc.
  * and also because static dechunking buffer should be differentiated per
  * library (here we assume different functions referring to this macro
  * will not ever be using both at once), preferably also per-library
  * context of use to avoid clashes altogether.
  *
  * Note that we cannot use qb_logt, because callsite data have to be known
  * at the moment of compilation, which it is not always the case -- xml_log
  * (and unfortunately there's no clear explanation of the fail to compile).
  *
  * Also note that there's no explicit guard against said libraries producing
  * never-newline-terminated chunks (which would just keep consuming memory),
  * as it's quite improbable.  Termination of the program in between the
  * same-message chunks will raise a flag with valgrind and the likes, though.
  *
  * And lastly, regarding how dechunking combines with other non-message
  * parameters -- for \p priority, most important running specification
  * wins (possibly elevated to LOG_ERR in case of nonconformance with the
  * newline-termination "protocol"), \p dechunk is expected to always be
  * on once it was at the start, and the rest (\p postemit and \p prefix)
  * are picked directly from the last chunk entry finalizing the message
  * (also reasonable to always have it the same with all related entries).
  *
  * \param[in] priority Syslog priority for the message to be logged
  * \param[in] dechunk  Whether to dechunk new-line terminated message
  * \param[in] postemit Code to be executed once message is sent out
  * \param[in] prefix   How to prefix the message or NULL for raw passing
  * \param[in] fmt      Format string as with printf-like functions
  * \param[in] ap       Variable argument list to supplement \p fmt format string
  */
 #define PCMK__XML_LOG_BASE(priority, dechunk, postemit, prefix, fmt, ap)        \
 do {                                                                            \
     if (!(dechunk) && (prefix) == NULL) {  /* quick pass */                     \
         qb_log_from_external_source_va(__func__, __FILE__, (fmt),               \
                                        (priority), __LINE__, 0, (ap));          \
         (void) (postemit);                                                      \
     } else {                                                                    \
         int CXLB_len = 0;                                                       \
         char *CXLB_buf = NULL;                                                  \
         static int CXLB_buffer_len = 0;                                         \
         static char *CXLB_buffer = NULL;                                        \
         static uint8_t CXLB_priority = 0;                                       \
                                                                                 \
         CXLB_len = vasprintf(&CXLB_buf, (fmt), (ap));                           \
                                                                                 \
         if (CXLB_len <= 0 || CXLB_buf[CXLB_len - 1] == '\n' || !(dechunk)) {    \
             if (CXLB_len < 0) {                                                 \
                 CXLB_buf = (char *) "LOG CORRUPTION HAZARD"; /*we don't modify*/\
                 CXLB_priority = QB_MIN(CXLB_priority, LOG_ERR);                 \
             } else if (CXLB_len > 0 /* && (dechunk) */                          \
                        && CXLB_buf[CXLB_len - 1] == '\n') {                     \
                 CXLB_buf[CXLB_len - 1] = '\0';                                  \
             }                                                                   \
             if (CXLB_buffer) {                                                  \
                 qb_log_from_external_source(__func__, __FILE__, "%s%s%s",       \
                                             CXLB_priority, __LINE__, 0,         \
                                             (prefix) != NULL ? (prefix) : "",   \
                                             CXLB_buffer, CXLB_buf);             \
                 free(CXLB_buffer);                                              \
             } else {                                                            \
                 qb_log_from_external_source(__func__, __FILE__, "%s%s",         \
                                             (priority), __LINE__, 0,            \
                                             (prefix) != NULL ? (prefix) : "",   \
                                             CXLB_buf);                          \
             }                                                                   \
             if (CXLB_len < 0) {                                                 \
                 CXLB_buf = NULL;  /* restore temporary override */              \
             }                                                                   \
             CXLB_buffer = NULL;                                                 \
             CXLB_buffer_len = 0;                                                \
             (void) (postemit);                                                  \
                                                                                 \
         } else if (CXLB_buffer == NULL) {                                       \
             CXLB_buffer_len = CXLB_len;                                         \
             CXLB_buffer = CXLB_buf;                                             \
             CXLB_buf = NULL;                                                    \
             CXLB_priority = (priority);  /* remember as a running severest */   \
                                                                                 \
         } else {                                                                \
             CXLB_buffer = realloc(CXLB_buffer, 1 + CXLB_buffer_len + CXLB_len); \
             memcpy(CXLB_buffer + CXLB_buffer_len, CXLB_buf, CXLB_len);          \
             CXLB_buffer_len += CXLB_len;                                        \
             CXLB_buffer[CXLB_buffer_len] = '\0';                                \
             CXLB_priority = QB_MIN(CXLB_priority, (priority));  /* severest? */ \
         }                                                                       \
         free(CXLB_buf);                                                         \
     }                                                                           \
 } while (0)
 
 /*!
  * \internal
  * \brief Bit flags to control format in XML logs and dumps
  */
 enum pcmk__xml_fmt_options {
     //! Exclude certain XML attributes (for calculating digests)
     pcmk__xml_fmt_filtered   = (1 << 0),
 
     //! Include indentation and newlines
     pcmk__xml_fmt_pretty     = (1 << 1),
 
     //! Include the opening tag of an XML element, and include XML comments
     pcmk__xml_fmt_open       = (1 << 3),
 
     //! Include the children of an XML element
     pcmk__xml_fmt_children   = (1 << 4),
 
     //! Include the closing tag of an XML element
     pcmk__xml_fmt_close      = (1 << 5),
 
     // @COMPAT Can we start including text nodes unconditionally?
     //! Include XML text nodes
     pcmk__xml_fmt_text       = (1 << 6),
 };
 
 void pcmk__xml_init(void);
 void pcmk__xml_cleanup(void);
 
 int pcmk__xml_show(pcmk__output_t *out, const char *prefix, const xmlNode *data,
                    int depth, uint32_t options);
 int pcmk__xml_show_changes(pcmk__output_t *out, const xmlNode *xml);
 
 enum pcmk__xml_artefact_ns {
     pcmk__xml_artefact_ns_legacy_rng = 1,
     pcmk__xml_artefact_ns_legacy_xslt,
     pcmk__xml_artefact_ns_base_rng,
     pcmk__xml_artefact_ns_base_xslt,
 };
 
 void pcmk__strip_xml_text(xmlNode *xml);
 
 /*!
  * \internal
  * \brief Indicators of which XML characters to escape
  *
  * XML allows the escaping of special characters by replacing them with entity
  * references (for example, <tt>"&quot;"</tt>) or character references (for
  * example, <tt>"&#13;"</tt>).
  *
  * The special characters <tt>'&'</tt> (except as the beginning of an entity
  * reference) and <tt>'<'</tt> are not allowed in their literal forms in XML
  * character data. Character data is non-markup text (for example, the content
  * of a text node). <tt>'>'</tt> is allowed under most circumstances; we escape
  * it for safety and symmetry.
  *
  * For more details, see the "Character Data and Markup" section of the XML
  * spec, currently section 2.4:
  * https://www.w3.org/TR/xml/#dt-markup
  *
  * Attribute values are handled specially.
  * * If an attribute value is delimited by single quotes, then single quotes
  *   must be escaped within the value.
  * * Similarly, if an attribute value is delimited by double quotes, then double
  *   quotes must be escaped within the value.
  * * A conformant XML processor replaces a literal whitespace character (tab,
  *   newline, carriage return, space) in an attribute value with a space
  *   (\c '#x20') character. However, a reference to a whitespace character (for
  *   example, \c "&#x0A;" for \c '\n') does not get replaced.
  *   * For more details, see the "Attribute-Value Normalization" section of the
  *     XML spec, currently section 3.3.3. Note that the default attribute type
  *     is CDATA; we don't deal with NMTOKENS, etc.:
  *     https://www.w3.org/TR/xml/#AVNormalize
  *
  * Pacemaker always delimits attribute values with double quotes, so there's no
  * need to escape single quotes.
  *
  * Newlines and tabs should be escaped in attribute values when XML is
  * serialized to text, so that future parsing preserves them rather than
  * normalizing them to spaces.
  *
  * We always escape carriage returns, so that they're not converted to spaces
  * during attribute-value normalization and because displaying them as literals
  * is messy.
  */
 enum pcmk__xml_escape_type {
     /*!
      * For text nodes.
      * * Escape \c '<', \c '>', and \c '&' using entity references.
      * * Do not escape \c '\n' and \c '\t'.
      * * Escape other non-printing characters using character references.
      */
     pcmk__xml_escape_text,
 
     /*!
      * For attribute values.
      * * Escape \c '<', \c '>', \c '&', and \c '"' using entity references.
      * * Escape \c '\n', \c '\t', and other non-printing characters using
      *   character references.
      */
     pcmk__xml_escape_attr,
 
     /* @COMPAT Drop escaping of at least '\n' and '\t' for
      * pcmk__xml_escape_attr_pretty when openstack-info, openstack-floating-ip,
      * and openstack-virtual-ip resource agents no longer depend on it.
      *
      * At time of writing, openstack-info may set a multiline value for the
      * openstack_ports node attribute. The other two agents query the value and
      * require it to be on one line with no spaces.
      */
     /*!
      * For attribute values displayed in text output delimited by double quotes.
      * * Escape \c '\n' as \c "\\n"
      * * Escape \c '\r' as \c "\\r"
      * * Escape \c '\t' as \c "\\t"
      * * Escape \c '"' as \c "\\""
      */
     pcmk__xml_escape_attr_pretty,
 };
 
 bool pcmk__xml_needs_escape(const char *text, enum pcmk__xml_escape_type type);
 char *pcmk__xml_escape(const char *text, enum pcmk__xml_escape_type type);
 
 /*!
  * \internal
  * \brief Get the root directory to scan XML artefacts of given kind for
  *
  * \param[in] ns governs the hierarchy nesting against the inherent root dir
  *
  * \return root directory to scan XML artefacts of given kind for
  */
 char *
 pcmk__xml_artefact_root(enum pcmk__xml_artefact_ns ns);
 
 /*!
  * \internal
  * \brief Get the fully unwrapped path to particular XML artifact (RNG/XSLT)
  *
  * \param[in] ns       denotes path forming details (parent dir, suffix)
  * \param[in] filespec symbolic file specification to be combined with
  *                     #artefact_ns to form the final path
  * \return unwrapped path to particular XML artifact (RNG/XSLT)
  */
 char *pcmk__xml_artefact_path(enum pcmk__xml_artefact_ns ns,
                               const char *filespec);
 
 /*!
  * \internal
  * \brief Return first non-text child node of an XML node
  *
  * \param[in] parent  XML node to check
  *
  * \return First non-text child node of \p parent (or NULL if none)
  */
 static inline xmlNode *
 pcmk__xml_first_child(const xmlNode *parent)
 {
     xmlNode *child = (parent? parent->children : NULL);
 
     while (child && (child->type == XML_TEXT_NODE)) {
         child = child->next;
     }
     return child;
 }
 
 /*!
  * \internal
  * \brief Return next non-text sibling node of an XML node
  *
  * \param[in] child  XML node to check
  *
  * \return Next non-text sibling of \p child (or NULL if none)
  */
 static inline xmlNode *
 pcmk__xml_next(const xmlNode *child)
 {
     xmlNode *next = (child? child->next : NULL);
 
     while (next && (next->type == XML_TEXT_NODE)) {
         next = next->next;
     }
     return next;
 }
 
 void pcmk__xml_free(xmlNode *xml);
 void pcmk__xml_free_doc(xmlDoc *doc);
 xmlNode *pcmk__xml_copy(xmlNode *parent, xmlNode *src);
 
 /*!
  * \internal
  * \brief Flags for operations affecting XML attributes
  */
 enum pcmk__xa_flags {
     //! Flag has no effect
     pcmk__xaf_none          = 0U,
 
     //! Don't overwrite existing values
     pcmk__xaf_no_overwrite  = (1U << 0),
 
     /*!
      * Treat values as score updates where possible (see
      * \c pcmk__xe_set_score())
      */
     pcmk__xaf_score_update  = (1U << 1),
 };
 
 void pcmk__xml_sanitize_id(char *id);
 
 /* internal XML-related utilities */
 
-enum xml_private_flags {
-     pcmk__xf_none        = 0x0000,
-     pcmk__xf_dirty       = 0x0001,
-     pcmk__xf_deleted     = 0x0002,
-     pcmk__xf_created     = 0x0004,
-     pcmk__xf_modified    = 0x0008,
-
-     pcmk__xf_tracking    = 0x0010,
-     pcmk__xf_processed   = 0x0020,
-     pcmk__xf_skip        = 0x0040,
-     pcmk__xf_moved       = 0x0080,
-
-     pcmk__xf_acl_enabled = 0x0100,
-     pcmk__xf_acl_read    = 0x0200,
-     pcmk__xf_acl_write   = 0x0400,
-     pcmk__xf_acl_deny    = 0x0800,
-
-     pcmk__xf_acl_create  = 0x1000,
-     pcmk__xf_acl_denied  = 0x2000,
-     pcmk__xf_lazy        = 0x4000,
+/*!
+ * \internal
+ * \brief Flags related to XML change tracking and ACLs
+ */
+enum pcmk__xml_flags {
+    //! This flag has no effect
+    pcmk__xf_none            = UINT32_C(0),
+
+    /*!
+     * Node was created or modified, or one of its descendants was created,
+     * modified, moved, or deleted.
+     */
+    pcmk__xf_dirty           = (UINT32_C(1) << 0),
+
+    //! Node was deleted (set for attribute only)
+    pcmk__xf_deleted         = (UINT32_C(1) << 1),
+
+    //! Node was created
+    pcmk__xf_created         = (UINT32_C(1) << 2),
+
+    //! Node was modified
+    pcmk__xf_modified        = (UINT32_C(1) << 3),
+
+    /*!
+     * \brief Tracking is enabled (set for document only)
+     *
+     * Call \c pcmk__xml_commit_changes() before setting this flag if a clean
+     * start for tracking is needed.
+     */
+    pcmk__xf_tracking        = (UINT32_C(1) << 4),
+
+    //! Skip counting this node when getting a node's position among siblings
+    pcmk__xf_skip            = (UINT32_C(1) << 6),
+
+    //! Node was moved
+    pcmk__xf_moved           = (UINT32_C(1) << 7),
+
+    //! ACLs are enabled (set for document only)
+    pcmk__xf_acl_enabled     = (UINT32_C(1) << 8),
+
+    /* @TODO Consider splitting the ACL permission flags (pcmk__xf_acl_read,
+     * pcmk__xf_acl_write, pcmk__xf_acl_write, and pcmk__xf_acl_create) into a
+     * separate enum and reserving this enum for tracking-related flags.
+     *
+     * The ACL permission flags have various meanings in different contexts (for
+     * example, what permission an ACL grants or denies; what permissions the
+     * current ACL user has for a given XML node; and possibly others). And
+     * for xml_acl_t objects, they're used in exclusive mode (exactly one is
+     * set), rather than as flags.
+     */
+
+    //! ACL read permission
+    pcmk__xf_acl_read        = (UINT32_C(1) << 9),
+
+    //! ACL write permission (implies read permission in most or all contexts)
+    pcmk__xf_acl_write       = (UINT32_C(1) << 10),
+
+    //! ACL deny permission (that is, no permission)
+    pcmk__xf_acl_deny        = (UINT32_C(1) << 11),
+
+    /*!
+     * ACL create permission for attributes (if attribute exists, this is mapped
+     * to \c pcmk__xf_acl_write)
+     */
+    pcmk__xf_acl_create      = (UINT32_C(1) << 12),
+
+    //! ACLs deny the user access (set for document only)
+    pcmk__xf_acl_denied      = (UINT32_C(1) << 13),
+
+    //! Ignore attribute moves within an element (set for document only)
+    pcmk__xf_ignore_attr_pos = (UINT32_C(1) << 14),
 };
 
 void pcmk__xml_doc_set_flags(xmlDoc *doc, uint32_t flags);
 bool pcmk__xml_doc_all_flags_set(const xmlDoc *xml, uint32_t flags);
 
+void pcmk__xml_commit_changes(xmlDoc *doc);
+void pcmk__xml_mark_changes(xmlNode *old_xml, xmlNode *new_xml);
+
 bool pcmk__xml_tree_foreach(xmlNode *xml, bool (*fn)(xmlNode *, void *),
                             void *user_data);
 
 static inline const char *
 pcmk__xml_attr_value(const xmlAttr *attr)
 {
     return ((attr == NULL) || (attr->children == NULL))? NULL
            : (const char *) attr->children->content;
 }
 
 /*!
  * \internal
  * \brief Check whether a given CIB element was modified in a CIB patchset
  *
  * \param[in] patchset  CIB XML patchset
  * \param[in] element   XML tag of CIB element to check (\c NULL is equivalent
  *                      to \c PCMK_XE_CIB). Supported values include any CIB
  *                      element supported by \c pcmk__cib_abs_xpath_for().
  *
  * \return \c true if \p element was modified, or \c false otherwise
  */
 bool pcmk__cib_element_in_patchset(const xmlNode *patchset,
                                    const char *element);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_XML_INTERNAL__H
diff --git a/lib/cib/cib_utils.c b/lib/cib/cib_utils.c
index 2698194680..26a16a7f62 100644
--- a/lib/cib/cib_utils.c
+++ b/lib/cib/cib_utils.c
@@ -1,935 +1,954 @@
 /*
  * Original copyright 2004 International Business Machines
- * Later changes copyright 2008-2024 the Pacemaker project contributors
+ * Later changes copyright 2008-2025 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 <unistd.h>
 #include <stdlib.h>
 #include <stdio.h>
 #include <stdarg.h>
 #include <string.h>
 #include <sys/utsname.h>
 
 #include <glib.h>
 
 #include <crm/crm.h>
 #include <crm/cib/internal.h>
 #include <crm/common/cib_internal.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 
 gboolean
 cib_version_details(xmlNode * cib, int *admin_epoch, int *epoch, int *updates)
 {
     *epoch = -1;
     *updates = -1;
     *admin_epoch = -1;
 
     if (cib == NULL) {
         return FALSE;
 
     } else {
         crm_element_value_int(cib, PCMK_XA_EPOCH, epoch);
         crm_element_value_int(cib, PCMK_XA_NUM_UPDATES, updates);
         crm_element_value_int(cib, PCMK_XA_ADMIN_EPOCH, admin_epoch);
     }
     return TRUE;
 }
 
 gboolean
 cib_diff_version_details(xmlNode * diff, int *admin_epoch, int *epoch, int *updates,
                          int *_admin_epoch, int *_epoch, int *_updates)
 {
     int add[] = { 0, 0, 0 };
     int del[] = { 0, 0, 0 };
 
     xml_patch_versions(diff, add, del);
 
     *admin_epoch = add[0];
     *epoch = add[1];
     *updates = add[2];
 
     *_admin_epoch = del[0];
     *_epoch = del[1];
     *_updates = del[2];
 
     return TRUE;
 }
 
 /*!
  * \internal
  * \brief Get the XML patchset from a CIB diff notification
  *
  * \param[in]  msg       CIB diff notification
  * \param[out] patchset  Where to store XML patchset
  *
  * \return Standard Pacemaker return code
  */
 int
 cib__get_notify_patchset(const xmlNode *msg, const xmlNode **patchset)
 {
     int rc = pcmk_err_generic;
     xmlNode *wrapper = NULL;
 
     pcmk__assert(patchset != NULL);
     *patchset = NULL;
 
     if (msg == NULL) {
         crm_err("CIB diff notification received with no XML");
         return ENOMSG;
     }
 
     if ((crm_element_value_int(msg, PCMK__XA_CIB_RC, &rc) != 0)
         || (rc != pcmk_ok)) {
 
         crm_warn("Ignore failed CIB update: %s " QB_XS " rc=%d",
                  pcmk_strerror(rc), rc);
         crm_log_xml_debug(msg, "failed");
         return pcmk_legacy2rc(rc);
     }
 
     wrapper = pcmk__xe_first_child(msg, PCMK__XE_CIB_UPDATE_RESULT, NULL, NULL);
     *patchset = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
 
     if (*patchset == NULL) {
         crm_err("CIB diff notification received with no patchset");
         return ENOMSG;
     }
     return pcmk_rc_ok;
 }
 
 /*!
  * \brief Create XML for a new (empty) CIB
  *
  * \param[in] cib_epoch  What to use as \c PCMK_XA_EPOCH CIB attribute
  *
  * \return Newly created XML for empty CIB
  *
  * \note It is the caller's responsibility to free the result with
  *       \c pcmk__xml_free().
  */
 xmlNode *
 createEmptyCib(int cib_epoch)
 {
     xmlNode *cib_root = NULL, *config = NULL;
 
     cib_root = pcmk__xe_create(NULL, PCMK_XE_CIB);
     crm_xml_add(cib_root, PCMK_XA_CRM_FEATURE_SET, CRM_FEATURE_SET);
     crm_xml_add(cib_root, PCMK_XA_VALIDATE_WITH, pcmk__highest_schema_name());
 
     crm_xml_add_int(cib_root, PCMK_XA_EPOCH, cib_epoch);
     crm_xml_add_int(cib_root, PCMK_XA_NUM_UPDATES, 0);
     crm_xml_add_int(cib_root, PCMK_XA_ADMIN_EPOCH, 0);
 
     config = pcmk__xe_create(cib_root, PCMK_XE_CONFIGURATION);
     pcmk__xe_create(cib_root, PCMK_XE_STATUS);
 
     pcmk__xe_create(config, PCMK_XE_CRM_CONFIG);
     pcmk__xe_create(config, PCMK_XE_NODES);
     pcmk__xe_create(config, PCMK_XE_RESOURCES);
     pcmk__xe_create(config, PCMK_XE_CONSTRAINTS);
 
 #if PCMK__RESOURCE_STICKINESS_DEFAULT != 0
     {
         xmlNode *rsc_defaults = pcmk__xe_create(config, PCMK_XE_RSC_DEFAULTS);
         xmlNode *meta = pcmk__xe_create(rsc_defaults, PCMK_XE_META_ATTRIBUTES);
         xmlNode *nvpair = pcmk__xe_create(meta, PCMK_XE_NVPAIR);
 
         crm_xml_add(meta, PCMK_XA_ID, "build-resource-defaults");
         crm_xml_add(nvpair, PCMK_XA_ID, "build-" PCMK_META_RESOURCE_STICKINESS);
         crm_xml_add(nvpair, PCMK_XA_NAME, PCMK_META_RESOURCE_STICKINESS);
         crm_xml_add_int(nvpair, PCMK_XA_VALUE,
                         PCMK__RESOURCE_STICKINESS_DEFAULT);
     }
 #endif
     return cib_root;
 }
 
 static bool
 cib_acl_enabled(xmlNode *xml, const char *user)
 {
     bool rc = FALSE;
 
     if(pcmk_acl_required(user)) {
         const char *value = NULL;
         GHashTable *options = pcmk__strkey_table(free, free);
 
         cib_read_config(options, xml);
         value = pcmk__cluster_option(options, PCMK_OPT_ENABLE_ACL);
         rc = crm_is_true(value);
         g_hash_table_destroy(options);
     }
 
     crm_trace("CIB ACL is %s", rc ? "enabled" : "disabled");
     return rc;
 }
 
 /*!
  * \internal
  * \brief Determine whether to perform operations on a scratch copy of the CIB
  *
  * \param[in] op            CIB operation
  * \param[in] section       CIB section
  * \param[in] call_options  CIB call options
  *
  * \return \p true if we should make a copy of the CIB, or \p false otherwise
  */
 static bool
 should_copy_cib(const char *op, const char *section, int call_options)
 {
     if (pcmk_is_set(call_options, cib_dryrun)) {
         // cib_dryrun implies a scratch copy by definition; no side effects
         return true;
     }
 
     if (pcmk__str_eq(op, PCMK__CIB_REQUEST_COMMIT_TRANSACT, pcmk__str_none)) {
         /* Commit-transaction must make a copy for atomicity. We must revert to
          * the original CIB if the entire transaction cannot be applied
          * successfully.
          */
         return true;
     }
 
     if (pcmk_is_set(call_options, cib_transaction)) {
         /* If cib_transaction is set, then we're in the process of committing a
          * transaction. The commit-transaction request already made a scratch
          * copy, and we're accumulating changes in that copy.
          */
         return false;
     }
 
     if (pcmk__str_eq(section, PCMK_XE_STATUS, pcmk__str_none)) {
         /* Copying large CIBs accounts for a huge percentage of our CIB usage,
          * and this avoids some of it.
          *
          * @TODO: Is this safe? See discussion at
          * https://github.com/ClusterLabs/pacemaker/pull/3094#discussion_r1211400690.
          */
         return false;
     }
 
     // Default behavior is to operate on a scratch copy
     return true;
 }
 
 int
 cib_perform_op(cib_t *cib, const char *op, uint32_t call_options,
                cib__op_fn_t fn, bool is_query, const char *section,
                xmlNode *req, xmlNode *input, bool manage_counters,
                bool *config_changed, xmlNode **current_cib,
                xmlNode **result_cib, xmlNode **diff, xmlNode **output)
 {
     int rc = pcmk_ok;
     bool check_schema = true;
     bool make_copy = true;
     xmlNode *top = NULL;
     xmlNode *scratch = NULL;
     xmlNode *patchset_cib = NULL;
     xmlNode *local_diff = NULL;
 
     const char *user = crm_element_value(req, PCMK__XA_CIB_USER);
+    const bool enable_acl = cib_acl_enabled(*current_cib, user);
     bool with_digest = false;
 
     crm_trace("Begin %s%s%s op",
               (pcmk_is_set(call_options, cib_dryrun)? "dry run of " : ""),
               (is_query? "read-only " : ""), op);
 
     CRM_CHECK(output != NULL, return -ENOMSG);
     CRM_CHECK(current_cib != NULL, return -ENOMSG);
     CRM_CHECK(result_cib != NULL, return -ENOMSG);
     CRM_CHECK(config_changed != NULL, return -ENOMSG);
 
     if(output) {
         *output = NULL;
     }
 
     *result_cib = NULL;
     *config_changed = false;
 
     if (fn == NULL) {
         return -EINVAL;
     }
 
     if (is_query) {
         xmlNode *cib_ro = *current_cib;
         xmlNode *cib_filtered = NULL;
 
-        if (cib_acl_enabled(cib_ro, user)
+        if (enable_acl
             && xml_acl_filtered_copy(user, *current_cib, *current_cib,
                                      &cib_filtered)) {
 
             if (cib_filtered == NULL) {
                 crm_debug("Pre-filtered the entire cib");
                 return -EACCES;
             }
             cib_ro = cib_filtered;
             crm_log_xml_trace(cib_ro, "filtered");
         }
 
         rc = (*fn) (op, call_options, section, req, input, cib_ro, result_cib, output);
 
         if(output == NULL || *output == NULL) {
             /* nothing */
 
         } else if(cib_filtered == *output) {
             cib_filtered = NULL; /* Let them have this copy */
 
         } else if (*output == *current_cib) {
             /* They already know not to free it */
 
         } else if(cib_filtered && (*output)->doc == cib_filtered->doc) {
             /* We're about to free the document of which *output is a part */
             *output = pcmk__xml_copy(NULL, *output);
 
         } else if ((*output)->doc == (*current_cib)->doc) {
             /* Give them a copy they can free */
             *output = pcmk__xml_copy(NULL, *output);
         }
 
         pcmk__xml_free(cib_filtered);
         return rc;
     }
 
     make_copy = should_copy_cib(op, section, call_options);
 
     if (!make_copy) {
         /* Conditional on v2 patch style */
 
         scratch = *current_cib;
 
         // Make a copy of the top-level element to store version details
         top = pcmk__xe_create(NULL, (const char *) scratch->name);
         pcmk__xe_copy_attrs(top, scratch, pcmk__xaf_none);
         patchset_cib = top;
 
-        xml_track_changes(scratch, user, NULL, cib_acl_enabled(scratch, user));
+        pcmk__xml_commit_changes(scratch->doc);
+        pcmk__xml_doc_set_flags(scratch->doc, pcmk__xf_tracking);
+        if (enable_acl) {
+            pcmk__enable_acl(*current_cib, scratch, user);
+        }
+
         rc = (*fn) (op, call_options, section, req, input, scratch, &scratch, output);
 
         /* If scratch points to a new object now (for example, after an erase
          * operation), then *current_cib should point to the same object.
+         *
+         * @TODO Enable tracking and ACLs and calculate changes? Change tracking
+         * and unpacked ACLs didn't carry over to new object.
          */
         *current_cib = scratch;
 
     } else {
         scratch = pcmk__xml_copy(NULL, *current_cib);
         patchset_cib = *current_cib;
 
-        xml_track_changes(scratch, user, NULL, cib_acl_enabled(scratch, user));
+        pcmk__xml_doc_set_flags(scratch->doc, pcmk__xf_tracking);
+        if (enable_acl) {
+            pcmk__enable_acl(*current_cib, scratch, user);
+        }
+
         rc = (*fn) (op, call_options, section, req, input, *current_cib,
                     &scratch, output);
 
+        /* @TODO This appears to be a hack to determine whether scratch points
+         * to a new object now, without saving the old pointer (which may be
+         * invalid now) for comparison. Confirm this, and check more clearly.
+         */
         if (!pcmk__xml_doc_all_flags_set(scratch->doc, pcmk__xf_tracking)) {
             crm_trace("Inferring changes after %s op", op);
-            xml_track_changes(scratch, user, *current_cib,
-                              cib_acl_enabled(*current_cib, user));
-            xml_calculate_changes(*current_cib, scratch);
+            pcmk__xml_commit_changes(scratch->doc);
+            if (enable_acl) {
+                pcmk__enable_acl(*current_cib, scratch, user);
+            }
+            pcmk__xml_mark_changes(*current_cib, scratch);
         }
         CRM_CHECK(*current_cib != scratch, return -EINVAL);
     }
 
     xml_acl_disable(scratch); /* Allow the system to make any additional changes */
 
     if (rc == pcmk_ok && scratch == NULL) {
         rc = -EINVAL;
         goto done;
 
     } else if(rc == pcmk_ok && xml_acl_denied(scratch)) {
         crm_trace("ACL rejected part or all of the proposed changes");
         rc = -EACCES;
         goto done;
 
     } else if (rc != pcmk_ok) {
         goto done;
     }
 
     /* If the CIB is from a file, we don't need to check that the feature set is
      * supported.  All we care about in that case is the schema version, which
      * is checked elsewhere.
      */
     if (scratch && (cib == NULL || cib->variant != cib_file)) {
         const char *new_version = crm_element_value(scratch, PCMK_XA_CRM_FEATURE_SET);
 
         rc = pcmk__check_feature_set(new_version);
         if (rc != pcmk_rc_ok) {
             crm_err("Discarding update with feature set '%s' greater than "
                     "our own '%s'", new_version, CRM_FEATURE_SET);
             rc = pcmk_rc2legacy(rc);
             goto done;
         }
     }
 
     if (patchset_cib != NULL) {
         int old = 0;
         int new = 0;
 
         crm_element_value_int(scratch, PCMK_XA_ADMIN_EPOCH, &new);
         crm_element_value_int(patchset_cib, PCMK_XA_ADMIN_EPOCH, &old);
 
         if (old > new) {
             crm_err("%s went backwards: %d -> %d (Opts: %#x)",
                     PCMK_XA_ADMIN_EPOCH, old, new, call_options);
             crm_log_xml_warn(req, "Bad Op");
             crm_log_xml_warn(input, "Bad Data");
             rc = -pcmk_err_old_data;
 
         } else if (old == new) {
             crm_element_value_int(scratch, PCMK_XA_EPOCH, &new);
             crm_element_value_int(patchset_cib, PCMK_XA_EPOCH, &old);
             if (old > new) {
                 crm_err("%s went backwards: %d -> %d (Opts: %#x)",
                         PCMK_XA_EPOCH, old, new, call_options);
                 crm_log_xml_warn(req, "Bad Op");
                 crm_log_xml_warn(input, "Bad Data");
                 rc = -pcmk_err_old_data;
             }
         }
     }
 
     crm_trace("Massaging CIB contents");
     pcmk__strip_xml_text(scratch);
 
     if (make_copy) {
         static time_t expires = 0;
         time_t tm_now = time(NULL);
 
         if (expires < tm_now) {
             expires = tm_now + 60;  /* Validate clients are correctly applying v2-style diffs at most once a minute */
             with_digest = true;
         }
     }
 
     local_diff = xml_create_patchset(0, patchset_cib, scratch,
                                      config_changed, manage_counters);
 
     pcmk__log_xml_changes(LOG_TRACE, scratch);
-    xml_accept_changes(scratch);
+    pcmk__xml_commit_changes(scratch->doc);
 
     if(local_diff) {
         patchset_process_digest(local_diff, patchset_cib, scratch, with_digest);
         pcmk__log_xml_patchset(LOG_INFO, local_diff);
         crm_log_xml_trace(local_diff, "raw patch");
     }
 
     if (make_copy && (local_diff != NULL)) {
         // Original to compare against doesn't exist
         pcmk__if_tracing(
             {
                 // Validate the calculated patch set
                 int test_rc = pcmk_ok;
                 int format = 1;
                 xmlNode *cib_copy = pcmk__xml_copy(NULL, patchset_cib);
 
                 crm_element_value_int(local_diff, PCMK_XA_FORMAT, &format);
                 test_rc = xml_apply_patchset(cib_copy, local_diff,
                                              manage_counters);
 
                 if (test_rc != pcmk_ok) {
                     save_xml_to_file(cib_copy, "PatchApply:calculated", NULL);
                     save_xml_to_file(patchset_cib, "PatchApply:input", NULL);
                     save_xml_to_file(scratch, "PatchApply:actual", NULL);
                     save_xml_to_file(local_diff, "PatchApply:diff", NULL);
                     crm_err("v%d patchset error, patch failed to apply: %s "
                             "(%d)",
                             format, pcmk_rc_str(pcmk_legacy2rc(test_rc)),
                             test_rc);
                 }
                 pcmk__xml_free(cib_copy);
             },
             {}
         );
     }
 
     if (pcmk__str_eq(section, PCMK_XE_STATUS, pcmk__str_casei)) {
         /* Throttle the amount of costly validation we perform due to status updates
          * a) we don't really care whats in the status section
          * b) we don't validate any of its contents at the moment anyway
          */
         check_schema = false;
     }
 
     /* === scratch must not be modified after this point ===
      * Exceptions, anything in:
 
      static filter_t filter[] = {
      { 0, PCMK_XA_CRM_DEBUG_ORIGIN },
      { 0, PCMK_XA_CIB_LAST_WRITTEN },
      { 0, PCMK_XA_UPDATE_ORIGIN },
      { 0, PCMK_XA_UPDATE_CLIENT },
      { 0, PCMK_XA_UPDATE_USER },
      };
      */
 
     if (*config_changed && !pcmk_is_set(call_options, cib_no_mtime)) {
         const char *schema = crm_element_value(scratch, PCMK_XA_VALIDATE_WITH);
 
         if (schema == NULL) {
             rc = -pcmk_err_cib_corrupt;
         }
 
         pcmk__xe_add_last_written(scratch);
         pcmk__warn_if_schema_deprecated(schema);
 
         /* Make values of origin, client, and user in scratch match
          * the ones in req (if the schema allows the attributes)
          */
         if (pcmk__cmp_schemas_by_name(schema, "pacemaker-1.2") >= 0) {
             const char *origin = crm_element_value(req, PCMK__XA_SRC);
             const char *client = crm_element_value(req,
                                                    PCMK__XA_CIB_CLIENTNAME);
 
             if (origin != NULL) {
                 crm_xml_add(scratch, PCMK_XA_UPDATE_ORIGIN, origin);
             } else {
                 pcmk__xe_remove_attr(scratch, PCMK_XA_UPDATE_ORIGIN);
             }
 
             if (client != NULL) {
                 crm_xml_add(scratch, PCMK_XA_UPDATE_CLIENT, user);
             } else {
                 pcmk__xe_remove_attr(scratch, PCMK_XA_UPDATE_CLIENT);
             }
 
             if (user != NULL) {
                 crm_xml_add(scratch, PCMK_XA_UPDATE_USER, user);
             } else {
                 pcmk__xe_remove_attr(scratch, PCMK_XA_UPDATE_USER);
             }
         }
     }
 
     crm_trace("Perform validation: %s", pcmk__btoa(check_schema));
     if ((rc == pcmk_ok) && check_schema
         && !pcmk__configured_schema_validates(scratch)) {
         rc = -pcmk_err_schema_validation;
     }
 
   done:
 
     *result_cib = scratch;
 
     /* @TODO: This may not work correctly with !make_copy, since we don't
      * keep the original CIB.
      */
     if ((rc != pcmk_ok) && cib_acl_enabled(patchset_cib, user)
         && xml_acl_filtered_copy(user, patchset_cib, scratch, result_cib)) {
 
         if (*result_cib == NULL) {
             crm_debug("Pre-filtered the entire cib result");
         }
         pcmk__xml_free(scratch);
     }
 
     if(diff) {
         *diff = local_diff;
     } else {
         pcmk__xml_free(local_diff);
     }
 
     pcmk__xml_free(top);
     crm_trace("Done");
     return rc;
 }
 
 int
 cib__create_op(cib_t *cib, const char *op, const char *host,
                const char *section, xmlNode *data, int call_options,
                const char *user_name, const char *client_name,
                xmlNode **op_msg)
 {
     CRM_CHECK((cib != NULL) && (op_msg != NULL), return -EPROTO);
 
     *op_msg = pcmk__xe_create(NULL, PCMK__XE_CIB_COMMAND);
 
     cib->call_id++;
     if (cib->call_id < 1) {
         cib->call_id = 1;
     }
 
     crm_xml_add(*op_msg, PCMK__XA_T, PCMK__VALUE_CIB);
     crm_xml_add(*op_msg, PCMK__XA_CIB_OP, op);
     crm_xml_add(*op_msg, PCMK__XA_CIB_HOST, host);
     crm_xml_add(*op_msg, PCMK__XA_CIB_SECTION, section);
     crm_xml_add(*op_msg, PCMK__XA_CIB_USER, user_name);
     crm_xml_add(*op_msg, PCMK__XA_CIB_CLIENTNAME, client_name);
     crm_xml_add_int(*op_msg, PCMK__XA_CIB_CALLID, cib->call_id);
 
     crm_trace("Sending call options: %.8lx, %d", (long)call_options, call_options);
     crm_xml_add_int(*op_msg, PCMK__XA_CIB_CALLOPT, call_options);
 
     if (data != NULL) {
         xmlNode *wrapper = pcmk__xe_create(*op_msg, PCMK__XE_CIB_CALLDATA);
 
         pcmk__xml_copy(wrapper, data);
     }
 
     return pcmk_ok;
 }
 
 /*!
  * \internal
  * \brief Check whether a CIB request is supported in a transaction
  *
  * \param[in] request  CIB request
  *
  * \return Standard Pacemaker return code
  */
 static int
 validate_transaction_request(const xmlNode *request)
 {
     const char *op = crm_element_value(request, PCMK__XA_CIB_OP);
     const char *host = crm_element_value(request, PCMK__XA_CIB_HOST);
     const cib__operation_t *operation = NULL;
     int rc = cib__get_operation(op, &operation);
 
     if (rc != pcmk_rc_ok) {
         // cib__get_operation() logs error
         return rc;
     }
 
     if (!pcmk_is_set(operation->flags, cib__op_attr_transaction)) {
         crm_err("Operation %s is not supported in CIB transactions", op);
         return EOPNOTSUPP;
     }
 
     if (host != NULL) {
         crm_err("Operation targeting a specific node (%s) is not supported in "
                 "a CIB transaction",
                 host);
         return EOPNOTSUPP;
     }
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Append a CIB request to a CIB transaction
  *
  * \param[in,out] cib      CIB client whose transaction to extend
  * \param[in,out] request  Request to add to transaction
  *
  * \return Legacy Pacemaker return code
  */
 int
 cib__extend_transaction(cib_t *cib, xmlNode *request)
 {
     int rc = pcmk_rc_ok;
 
     pcmk__assert((cib != NULL) && (request != NULL));
 
     rc = validate_transaction_request(request);
 
     if ((rc == pcmk_rc_ok) && (cib->transaction == NULL)) {
         rc = pcmk_rc_no_transaction;
     }
 
     if (rc == pcmk_rc_ok) {
         pcmk__xml_copy(cib->transaction, request);
 
     } else {
         const char *op = crm_element_value(request, PCMK__XA_CIB_OP);
         const char *client_id = NULL;
 
         cib->cmds->client_id(cib, NULL, &client_id);
         crm_err("Failed to add '%s' operation to transaction for client %s: %s",
                 op, pcmk__s(client_id, "(unidentified)"), pcmk_rc_str(rc));
         crm_log_xml_info(request, "failed");
     }
     return pcmk_rc2legacy(rc);
 }
 
 void
 cib_native_callback(cib_t * cib, xmlNode * msg, int call_id, int rc)
 {
     xmlNode *output = NULL;
     cib_callback_client_t *blob = NULL;
 
     if (msg != NULL) {
         xmlNode *wrapper = NULL;
 
         crm_element_value_int(msg, PCMK__XA_CIB_RC, &rc);
         crm_element_value_int(msg, PCMK__XA_CIB_CALLID, &call_id);
         wrapper = pcmk__xe_first_child(msg, PCMK__XE_CIB_CALLDATA, NULL, NULL);
         output = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
     }
 
     blob = cib__lookup_id(call_id);
 
     if (blob == NULL) {
         crm_trace("No callback found for call %d", call_id);
     }
 
     if (cib == NULL) {
         crm_debug("No cib object supplied");
     }
 
     if (rc == -pcmk_err_diff_resync) {
         /* This is an internal value that clients do not and should not care about */
         rc = pcmk_ok;
     }
 
     if (blob && blob->callback && (rc == pcmk_ok || blob->only_success == FALSE)) {
         crm_trace("Invoking callback %s for call %d",
                   pcmk__s(blob->id, "without ID"), call_id);
         blob->callback(msg, call_id, rc, output, blob->user_data);
 
     } else if ((cib != NULL) && (rc != pcmk_ok)) {
         crm_warn("CIB command failed: %s", pcmk_strerror(rc));
         crm_log_xml_debug(msg, "Failed CIB Update");
     }
 
     /* This may free user_data, so do it after the callback */
     if (blob) {
         remove_cib_op_callback(call_id, FALSE);
     }
 
     crm_trace("OP callback activated for %d", call_id);
 }
 
 void
 cib_native_notify(gpointer data, gpointer user_data)
 {
     xmlNode *msg = user_data;
     cib_notify_client_t *entry = data;
     const char *event = NULL;
 
     if (msg == NULL) {
         crm_warn("Skipping callback - NULL message");
         return;
     }
 
     event = crm_element_value(msg, PCMK__XA_SUBT);
 
     if (entry == NULL) {
         crm_warn("Skipping callback - NULL callback client");
         return;
 
     } else if (entry->callback == NULL) {
         crm_warn("Skipping callback - NULL callback");
         return;
 
     } else if (!pcmk__str_eq(entry->event, event, pcmk__str_casei)) {
         crm_trace("Skipping callback - event mismatch %p/%s vs. %s", entry, entry->event, event);
         return;
     }
 
     crm_trace("Invoking callback for %p/%s event...", entry, event);
     entry->callback(event, msg);
     crm_trace("Callback invoked...");
 }
 
 gboolean
 cib_read_config(GHashTable * options, xmlNode * current_cib)
 {
     xmlNode *config = NULL;
     crm_time_t *now = NULL;
 
     if (options == NULL || current_cib == NULL) {
         return FALSE;
     }
 
     now = crm_time_new(NULL);
 
     g_hash_table_remove_all(options);
 
     config = pcmk_find_cib_element(current_cib, PCMK_XE_CRM_CONFIG);
     if (config) {
         pcmk_rule_input_t rule_input = {
             .now = now,
         };
 
         pcmk_unpack_nvpair_blocks(config, PCMK_XE_CLUSTER_PROPERTY_SET,
                                   PCMK_VALUE_CIB_BOOTSTRAP_OPTIONS, &rule_input,
                                   options, NULL);
     }
 
     pcmk__validate_cluster_options(options);
 
     crm_time_free(now);
 
     return TRUE;
 }
 
 int
 cib_internal_op(cib_t * cib, const char *op, const char *host,
                 const char *section, xmlNode * data,
                 xmlNode ** output_data, int call_options, const char *user_name)
 {
     int (*delegate)(cib_t *cib, const char *op, const char *host,
                     const char *section, xmlNode *data, xmlNode **output_data,
                     int call_options, const char *user_name) = NULL;
 
     if (cib == NULL) {
         return -EINVAL;
     }
 
     delegate = cib->delegate_fn;
     if (delegate == NULL) {
         return -EPROTONOSUPPORT;
     }
     if (user_name == NULL) {
         user_name = getenv("CIB_user");
     }
     return delegate(cib, op, host, section, data, output_data, call_options, user_name);
 }
 
 /*!
  * \brief Apply a CIB update patch to a given CIB
  *
  * \param[in]  event   CIB update patch
  * \param[in]  input   CIB to patch
  * \param[out] output  Resulting CIB after patch
  * \param[in]  level   Log the patch at this log level (unless LOG_CRIT)
  *
  * \return Legacy Pacemaker return code
  * \note sbd calls this function
  */
 int
 cib_apply_patch_event(xmlNode *event, xmlNode *input, xmlNode **output,
                       int level)
 {
     int rc = pcmk_err_generic;
 
     xmlNode *wrapper = NULL;
     xmlNode *diff = NULL;
 
     pcmk__assert((event != NULL) && (input != NULL) && (output != NULL));
 
     crm_element_value_int(event, PCMK__XA_CIB_RC, &rc);
     wrapper = pcmk__xe_first_child(event, PCMK__XE_CIB_UPDATE_RESULT, NULL,
                                    NULL);
     diff = pcmk__xe_first_child(wrapper, NULL, NULL, NULL);
 
     if (rc < pcmk_ok || diff == NULL) {
         return rc;
     }
 
     if (level > LOG_CRIT) {
         pcmk__log_xml_patchset(level, diff);
     }
 
     if (input != NULL) {
         rc = cib_process_diff(NULL, cib_none, NULL, event, diff, input, output,
                               NULL);
 
         if (rc != pcmk_ok) {
             crm_debug("Update didn't apply: %s (%d) %p",
                       pcmk_strerror(rc), rc, *output);
 
             if (rc == -pcmk_err_old_data) {
                 crm_trace("Masking error, we already have the supplied update");
                 return pcmk_ok;
             }
             pcmk__xml_free(*output);
             *output = NULL;
             return rc;
         }
     }
     return rc;
 }
 
 #define log_signon_query_err(out, fmt, args...) do {    \
         if (out != NULL) {                              \
             out->err(out, fmt, ##args);                 \
         } else {                                        \
             crm_err(fmt, ##args);                       \
         }                                               \
     } while (0)
 
 int
 cib__signon_query(pcmk__output_t *out, cib_t **cib, xmlNode **cib_object)
 {
     int rc = pcmk_rc_ok;
     cib_t *cib_conn = NULL;
 
     pcmk__assert(cib_object != NULL);
 
     if (cib == NULL) {
         cib_conn = cib_new();
     } else {
         if (*cib == NULL) {
             *cib = cib_new();
         }
         cib_conn = *cib;
     }
 
     if (cib_conn == NULL) {
         return ENOMEM;
     }
 
     if (cib_conn->state == cib_disconnected) {
         rc = cib_conn->cmds->signon(cib_conn, crm_system_name, cib_command);
         rc = pcmk_legacy2rc(rc);
     }
 
     if (rc != pcmk_rc_ok) {
         log_signon_query_err(out, "Could not connect to the CIB: %s",
                              pcmk_rc_str(rc));
         goto done;
     }
 
     if (out != NULL) {
         out->transient(out, "Querying CIB...");
     }
     rc = cib_conn->cmds->query(cib_conn, NULL, cib_object, cib_sync_call);
     rc = pcmk_legacy2rc(rc);
 
     if (rc != pcmk_rc_ok) {
         log_signon_query_err(out, "CIB query failed: %s", pcmk_rc_str(rc));
     }
 
 done:
     if (cib == NULL) {
         cib__clean_up_connection(&cib_conn);
     }
 
     if ((rc == pcmk_rc_ok) && (*cib_object == NULL)) {
         return pcmk_rc_no_input;
     }
     return rc;
 }
 
 int
 cib__signon_attempts(cib_t *cib, enum cib_conn_type type, int attempts)
 {
     int rc = pcmk_rc_ok;
 
     crm_trace("Attempting connection to CIB manager (up to %d time%s)",
               attempts, pcmk__plural_s(attempts));
 
     for (int remaining = attempts - 1; remaining >= 0; --remaining) {
         rc = cib->cmds->signon(cib, crm_system_name, type);
 
         if ((rc == pcmk_rc_ok)
             || (remaining == 0)
             || ((errno != EAGAIN) && (errno != EALREADY))) {
             break;
         }
 
         // Retry after soft error (interrupted by signal, etc.)
         pcmk__sleep_ms((attempts - remaining) * 500);
         crm_debug("Re-attempting connection to CIB manager (%d attempt%s remaining)",
                   remaining, pcmk__plural_s(remaining));
     }
 
     return rc;
 }
 
 int
 cib__clean_up_connection(cib_t **cib)
 {
     int rc;
 
     if (*cib == NULL) {
         return pcmk_rc_ok;
     }
 
     rc = (*cib)->cmds->signoff(*cib);
     cib_delete(*cib);
     *cib = NULL;
     return pcmk_legacy2rc(rc);
 }
diff --git a/lib/common/acl.c b/lib/common/acl.c
index 9cdafa7016..e14d3f14c4 100644
--- a/lib/common/acl.c
+++ b/lib/common/acl.c
@@ -1,923 +1,922 @@
 /*
  * Copyright 2004-2025 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 <pwd.h>
 #include <string.h>
 #include <stdlib.h>
 #include <stdarg.h>
 
 #include <libxml/tree.h>                // xmlNode, etc.
 #include <libxml/xmlstring.h>           // xmlChar
 #include <libxml/xpath.h>               // xmlXPathObject, etc.
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 typedef struct xml_acl_s {
-        enum xml_private_flags mode;
-        gchar *xpath;
+    enum pcmk__xml_flags mode;
+    gchar *xpath;
 } xml_acl_t;
 
 static void
 free_acl(void *data)
 {
     if (data) {
         xml_acl_t *acl = data;
 
         g_free(acl->xpath);
         free(acl);
     }
 }
 
 void
 pcmk__free_acls(GList *acls)
 {
     g_list_free_full(acls, free_acl);
 }
 
 static GList *
-create_acl(const xmlNode *xml, GList *acls, enum xml_private_flags mode)
+create_acl(const xmlNode *xml, GList *acls, enum pcmk__xml_flags mode)
 {
     xml_acl_t *acl = NULL;
 
     const char *tag = crm_element_value(xml, PCMK_XA_OBJECT_TYPE);
     const char *ref = crm_element_value(xml, PCMK_XA_REFERENCE);
     const char *xpath = crm_element_value(xml, PCMK_XA_XPATH);
     const char *attr = crm_element_value(xml, PCMK_XA_ATTRIBUTE);
 
     if ((tag == NULL) && (ref == NULL) && (xpath == NULL)) {
         // Schema should prevent this, but to be safe ...
         crm_trace("Ignoring ACL <%s> element without selection criteria",
                   xml->name);
         return NULL;
     }
 
     acl = pcmk__assert_alloc(1, sizeof (xml_acl_t));
 
     acl->mode = mode;
     if (xpath) {
         acl->xpath = g_strdup(xpath);
         crm_trace("Unpacked ACL <%s> element using xpath: %s",
                   xml->name, acl->xpath);
 
     } else {
         GString *buf = g_string_sized_new(128);
 
         if ((ref != NULL) && (attr != NULL)) {
             // NOTE: schema currently does not allow this
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@" PCMK_XA_ID "='",
                            ref, "' and @", attr, "]", NULL);
 
         } else if (ref != NULL) {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@" PCMK_XA_ID "='",
                            ref, "']", NULL);
 
         } else if (attr != NULL) {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), "[@", attr, "]", NULL);
 
         } else {
             pcmk__g_strcat(buf, "//", pcmk__s(tag, "*"), NULL);
         }
 
         acl->xpath = buf->str;
 
         g_string_free(buf, FALSE);
         crm_trace("Unpacked ACL <%s> element as xpath: %s",
                   xml->name, acl->xpath);
     }
 
     return g_list_append(acls, acl);
 }
 
 /*!
  * \internal
  * \brief Unpack a user, group, or role subtree of the ACLs section
  *
  * \param[in]     acl_top    XML of entire ACLs section
  * \param[in]     acl_entry  XML of ACL element being unpacked
  * \param[in,out] acls       List of ACLs unpacked so far
  *
  * \return New head of (possibly modified) acls
  *
  * \note This function is recursive
  */
 static GList *
 parse_acl_entry(const xmlNode *acl_top, const xmlNode *acl_entry, GList *acls)
 {
     for (const xmlNode *child = pcmk__xe_first_child(acl_entry, NULL, NULL,
                                                      NULL);
          child != NULL; child = pcmk__xe_next(child, NULL)) {
 
         if (pcmk__xe_is(child, PCMK_XE_ACL_PERMISSION)) {
             const char *kind = crm_element_value(child, PCMK_XA_KIND);
 
             pcmk__assert(kind != NULL);
             crm_trace("Unpacking <" PCMK_XE_ACL_PERMISSION "> element of "
                       "kind '%s'",
                       kind);
 
             if (pcmk__str_eq(kind, PCMK_VALUE_READ, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_read);
 
             } else if (pcmk__str_eq(kind, PCMK_VALUE_WRITE, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_write);
 
             } else if (pcmk__str_eq(kind, PCMK_VALUE_DENY, pcmk__str_none)) {
                 acls = create_acl(child, acls, pcmk__xf_acl_deny);
 
             } else {
                 crm_warn("Ignoring unknown ACL kind '%s'", kind);
             }
 
         } else if (pcmk__xe_is(child, PCMK_XE_ROLE)) {
             const char *ref_role = crm_element_value(child, PCMK_XA_ID);
 
             crm_trace("Unpacking <" PCMK_XE_ROLE "> element");
 
             if (ref_role == NULL) {
                 continue;
             }
 
             for (xmlNode *role = pcmk__xe_first_child(acl_top, NULL, NULL,
                                                       NULL);
                  role != NULL; role = pcmk__xe_next(role, NULL)) {
 
                 const char *role_id = NULL;
 
                 if (!pcmk__xe_is(role, PCMK_XE_ACL_ROLE)) {
                     continue;
                 }
 
                 role_id = crm_element_value(role, PCMK_XA_ID);
 
                 if (pcmk__str_eq(ref_role, role_id, pcmk__str_none)) {
                     crm_trace("Unpacking referenced role '%s' in <%s> element",
                               role_id, acl_entry->name);
                     acls = parse_acl_entry(acl_top, role, acls);
                     break;
                 }
             }
         }
     }
 
     return acls;
 }
 
 /*
     <acls>
       <acl_target id="l33t-haxor"><role id="auto-l33t-haxor"/></acl_target>
       <acl_role id="auto-l33t-haxor">
         <acl_permission id="crook-nothing" kind="deny" xpath="/cib"/>
       </acl_role>
       <acl_target id="niceguy">
         <role id="observer"/>
       </acl_target>
       <acl_role id="observer">
         <acl_permission id="observer-read-1" kind="read" xpath="/cib"/>
         <acl_permission id="observer-write-1" kind="write" xpath="//nvpair[@name='stonith-enabled']"/>
         <acl_permission id="observer-write-2" kind="write" xpath="//nvpair[@name='target-role']"/>
       </acl_role>
       <acl_target id="badidea"><role id="auto-badidea"/></acl_target>
       <acl_role id="auto-badidea">
         <acl_permission id="badidea-resources" kind="read" xpath="//meta_attributes"/>
         <acl_permission id="badidea-resources-2" kind="deny" reference="dummy-meta_attributes"/>
       </acl_role>
     </acls>
 */
 
 static const char *
-acl_to_text(enum xml_private_flags flags)
+acl_to_text(enum pcmk__xml_flags flags)
 {
     if (pcmk_is_set(flags, pcmk__xf_acl_deny)) {
         return "deny";
 
     } else if (pcmk_any_flags_set(flags, pcmk__xf_acl_write|pcmk__xf_acl_create)) {
         return "read/write";
 
     } else if (pcmk_is_set(flags, pcmk__xf_acl_read)) {
         return "read";
     }
     return "none";
 }
 
 void
 pcmk__apply_acl(xmlNode *xml)
 {
     GList *aIter = NULL;
     xml_doc_private_t *docpriv = xml->doc->_private;
     xml_node_private_t *nodepriv;
     xmlXPathObject *xpathObj = NULL;
 
     if (!xml_acl_enabled(xml)) {
         crm_trace("Skipping ACLs for user '%s' because not enabled for this XML",
                   docpriv->acl_user);
         return;
     }
 
     for (aIter = docpriv->acls; aIter != NULL; aIter = aIter->next) {
         int max = 0, lpc = 0;
         xml_acl_t *acl = aIter->data;
 
         xpathObj = pcmk__xpath_search(xml->doc, acl->xpath);
         max = pcmk__xpath_num_results(xpathObj);
 
         for (lpc = 0; lpc < max; lpc++) {
             xmlNode *match = pcmk__xpath_result(xpathObj, lpc);
 
             if (match == NULL) {
                 continue;
             }
 
             /* @COMPAT If the ACL's XPath matches a node that is neither an
              * element nor a document, we apply the ACL to the parent element
              * rather than to the matched node. For example, if the XPath
              * matches a "score" attribute, then it applies to every element
              * that contains a "score" attribute. That is, the XPath expression
              * "//@score" matches all attributes named "score", but we apply the
              * ACL to all elements containing such an attribute.
              *
              * This behavior is incorrect from an XPath standpoint and is thus
              * confusing and counterintuitive. The correct way to match all
              * elements containing a "score" attribute is to use an XPath
              * predicate: "// *[@score]". (Space inserted after slashes so that
              * GCC doesn't throw an error about nested comments.)
              *
              * Additionally, if an XPath expression matches the entire document
              * (for example, "/"), then the ACL applies to the document's root
              * element if it exists.
              *
              * These behaviors should be changed so that the ACL applies to the
              * nodes matched by the XPath expression, or so that it doesn't
              * apply at all if applying an ACL to an attribute doesn't make
              * sense.
              *
              * Unfortunately, we document in Pacemaker Explained that matching
              * attributes is a valid way to match elements: "Attributes may be
              * specified in the XPath to select particular elements, but the
              * permissions apply to the entire element."
              *
              * So we have to keep this behavior at least until a compatibility
              * break. Even then, it's not feasible in the general case to
              * transform such XPath expressions using XSLT.
              */
             match = pcmk__xpath_match_element(match);
             if (match == NULL) {
                 continue;
             }
 
             nodepriv = match->_private;
             pcmk__set_xml_flags(nodepriv, acl->mode);
 
             // Build a GString only if tracing is enabled
             pcmk__if_tracing(
                 {
                     GString *path = pcmk__element_xpath(match);
                     crm_trace("Applying %s ACL to %s matched by %s",
                               acl_to_text(acl->mode), path->str, acl->xpath);
                     g_string_free(path, TRUE);
                 },
                 {}
             );
         }
         crm_trace("Applied %s ACL %s (%d match%s)",
                   acl_to_text(acl->mode), acl->xpath, max,
                   ((max == 1)? "" : "es"));
         xmlXPathFreeObject(xpathObj);
     }
 }
 
 /*!
  * \internal
  * \brief Unpack ACLs for a given user into the
  * metadata of the target XML tree
  *
  * Taking the description of ACLs from the source XML tree and
  * marking up the target XML tree with access information for the
  * given user by tacking it onto the relevant nodes
  *
  * \param[in]     source  XML with ACL definitions
  * \param[in,out] target  XML that ACLs will be applied to
  * \param[in]     user    Username whose ACLs need to be unpacked
  */
 void
 pcmk__unpack_acl(xmlNode *source, xmlNode *target, const char *user)
 {
     xml_doc_private_t *docpriv = NULL;
 
     if ((target == NULL) || (target->doc == NULL)
         || (target->doc->_private == NULL)) {
         return;
     }
 
     docpriv = target->doc->_private;
     if (!pcmk_acl_required(user)) {
         crm_trace("Not unpacking ACLs because not required for user '%s'",
                   user);
 
     } else if (docpriv->acls == NULL) {
         xmlNode *acls = pcmk__xpath_find_one(source->doc, "//" PCMK_XE_ACLS,
                                              LOG_NEVER);
 
         pcmk__str_update(&(docpriv->acl_user), user);
 
         if (acls) {
             xmlNode *child = NULL;
 
             for (child = pcmk__xe_first_child(acls, NULL, NULL, NULL);
                  child != NULL; child = pcmk__xe_next(child, NULL)) {
 
                 if (pcmk__xe_is(child, PCMK_XE_ACL_TARGET)) {
                     const char *id = crm_element_value(child, PCMK_XA_NAME);
 
                     if (id == NULL) {
                         id = crm_element_value(child, PCMK_XA_ID);
                     }
 
                     if (id && strcmp(id, user) == 0) {
                         crm_debug("Unpacking ACLs for user '%s'", id);
                         docpriv->acls = parse_acl_entry(acls, child, docpriv->acls);
                     }
                 } else if (pcmk__xe_is(child, PCMK_XE_ACL_GROUP)) {
                     const char *id = crm_element_value(child, PCMK_XA_NAME);
 
                     if (id == NULL) {
                         id = crm_element_value(child, PCMK_XA_ID);
                     }
 
                     if (id && pcmk__is_user_in_group(user,id)) {
                         crm_debug("Unpacking ACLs for group '%s'", id);
                         docpriv->acls = parse_acl_entry(acls, child, docpriv->acls);
                     }
                 }
             }
         }
     }
 }
 
 /*!
  * \internal
  * \brief Copy source to target and set xf_acl_enabled flag in target
  *
  * \param[in]     acl_source    XML with ACL definitions
  * \param[in,out] target        XML that ACLs will be applied to
  * \param[in]     user          Username whose ACLs need to be set
  */
 void
 pcmk__enable_acl(xmlNode *acl_source, xmlNode *target, const char *user)
 {
     if (target == NULL) {
         return;
     }
     pcmk__unpack_acl(acl_source, target, user);
     pcmk__xml_doc_set_flags(target->doc, pcmk__xf_acl_enabled);
     pcmk__apply_acl(target);
 }
 
 static inline bool
-test_acl_mode(enum xml_private_flags allowed, enum xml_private_flags requested)
+test_acl_mode(enum pcmk__xml_flags allowed, enum pcmk__xml_flags requested)
 {
     if (pcmk_is_set(allowed, pcmk__xf_acl_deny)) {
         return false;
 
     } else if (pcmk_all_flags_set(allowed, requested)) {
         return true;
 
     } else if (pcmk_is_set(requested, pcmk__xf_acl_read)
                && pcmk_is_set(allowed, pcmk__xf_acl_write)) {
         return true;
 
     } else if (pcmk_is_set(requested, pcmk__xf_acl_create)
                && pcmk_any_flags_set(allowed, pcmk__xf_acl_write|pcmk__xf_created)) {
         return true;
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Rid XML tree of all unreadable nodes and node properties
  *
  * \param[in,out] xml   Root XML node to be purged of attributes
  *
  * \return true if this node or any of its children are readable
  *         if false is returned, xml will be freed
  *
  * \note This function is recursive
  */
 static bool
 purge_xml_attributes(xmlNode *xml)
 {
     xmlNode *child = NULL;
     xmlAttr *xIter = NULL;
     bool readable_children = false;
     xml_node_private_t *nodepriv = xml->_private;
 
     if (test_acl_mode(nodepriv->flags, pcmk__xf_acl_read)) {
         crm_trace("%s[@" PCMK_XA_ID "=%s] is readable",
                   xml->name, pcmk__xe_id(xml));
         return true;
     }
 
     xIter = xml->properties;
     while (xIter != NULL) {
         xmlAttr *tmp = xIter;
         const char *prop_name = (const char *)xIter->name;
 
         xIter = xIter->next;
         if (strcmp(prop_name, PCMK_XA_ID) == 0) {
             continue;
         }
 
         pcmk__xa_remove(tmp, true);
     }
 
     child = pcmk__xml_first_child(xml);
     while ( child != NULL ) {
         xmlNode *tmp = child;
 
         child = pcmk__xml_next(child);
         readable_children |= purge_xml_attributes(tmp);
     }
 
     if (!readable_children) {
         // Nothing readable under here, so purge completely
         pcmk__xml_free(xml);
     }
     return readable_children;
 }
 
 /*!
  * \brief Copy ACL-allowed portions of specified XML
  *
  * \param[in]  user        Username whose ACLs should be used
  * \param[in]  acl_source  XML containing ACLs
  * \param[in]  xml         XML to be copied
  * \param[out] result      Copy of XML portions readable via ACLs
  *
  * \return true if xml exists and ACLs are required for user, false otherwise
  * \note If this returns true, caller should use \p result rather than \p xml
  */
 bool
 xml_acl_filtered_copy(const char *user, xmlNode *acl_source, xmlNode *xml,
                       xmlNode **result)
 {
     GList *aIter = NULL;
     xmlNode *target = NULL;
     xml_doc_private_t *docpriv = NULL;
 
     *result = NULL;
     if ((xml == NULL) || !pcmk_acl_required(user)) {
         crm_trace("Not filtering XML because ACLs not required for user '%s'",
                   user);
         return false;
     }
 
     crm_trace("Filtering XML copy using user '%s' ACLs", user);
     target = pcmk__xml_copy(NULL, xml);
     if (target == NULL) {
         return true;
     }
 
     pcmk__enable_acl(acl_source, target, user);
 
     docpriv = target->doc->_private;
     for(aIter = docpriv->acls; aIter != NULL && target; aIter = aIter->next) {
         int max = 0;
         xml_acl_t *acl = aIter->data;
 
         if (acl->mode != pcmk__xf_acl_deny) {
             /* Nothing to do */
 
         } else if (acl->xpath) {
             int lpc = 0;
             xmlXPathObject *xpathObj = pcmk__xpath_search(target->doc,
                                                           acl->xpath);
 
             max = pcmk__xpath_num_results(xpathObj);
             for(lpc = 0; lpc < max; lpc++) {
                 xmlNode *match = pcmk__xpath_result(xpathObj, lpc);
 
                 if (match == NULL) {
                     continue;
                 }
 
                 // @COMPAT See COMPAT comment in pcmk__apply_acl()
                 match = pcmk__xpath_match_element(match);
                 if (match == NULL) {
                     continue;
                 }
 
                 if (!purge_xml_attributes(match) && (match == target)) {
                     crm_trace("ACLs deny user '%s' access to entire XML document",
                               user);
                     xmlXPathFreeObject(xpathObj);
                     return true;
                 }
             }
             crm_trace("ACLs deny user '%s' access to %s (%d %s)",
                       user, acl->xpath, max,
                       pcmk__plural_alt(max, "match", "matches"));
             xmlXPathFreeObject(xpathObj);
         }
     }
 
     if (!purge_xml_attributes(target)) {
         crm_trace("ACLs deny user '%s' access to entire XML document", user);
         return true;
     }
 
     if (docpriv->acls) {
         g_list_free_full(docpriv->acls, free_acl);
         docpriv->acls = NULL;
 
     } else {
         crm_trace("User '%s' without ACLs denied access to entire XML document",
                   user);
         pcmk__xml_free(target);
         target = NULL;
     }
 
     if (target) {
         *result = target;
     }
 
     return true;
 }
 
 /*!
  * \internal
  * \brief Check whether creation of an XML element is implicitly allowed
  *
  * Check whether XML is a "scaffolding" element whose creation is implicitly
  * allowed regardless of ACLs (that is, it is not in the ACL section and has
  * no attributes other than \c PCMK_XA_ID).
  *
  * \param[in] xml  XML element to check
  *
  * \return true if XML element is implicitly allowed, false otherwise
  */
 static bool
 implicitly_allowed(const xmlNode *xml)
 {
     GString *path = NULL;
 
     for (xmlAttr *prop = xml->properties; prop != NULL; prop = prop->next) {
         if (strcmp((const char *) prop->name, PCMK_XA_ID) != 0) {
             return false;
         }
     }
 
     path = pcmk__element_xpath(xml);
     pcmk__assert(path != NULL);
 
     if (strstr((const char *) path->str, "/" PCMK_XE_ACLS "/") != NULL) {
         g_string_free(path, TRUE);
         return false;
     }
 
     g_string_free(path, TRUE);
     return true;
 }
 
 #define display_id(xml) pcmk__s(pcmk__xe_id(xml), "<unset>")
 
 /*!
  * \internal
  * \brief Drop XML nodes created in violation of ACLs
  *
  * Given an XML element, free all of its descendant nodes created in violation
  * of ACLs, with the exception of allowing "scaffolding" elements (i.e. those
  * that aren't in the ACL section and don't have any attributes other than
  * \c PCMK_XA_ID).
  *
  * \param[in,out] xml        XML to check
  * \param[in]     check_top  Whether to apply checks to argument itself
  *                           (if true, xml might get freed)
  *
  * \note This function is recursive
  */
 void
 pcmk__apply_creation_acl(xmlNode *xml, bool check_top)
 {
     xml_node_private_t *nodepriv = xml->_private;
 
     if (pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
         if (implicitly_allowed(xml)) {
             crm_trace("Creation of <%s> scaffolding with " PCMK_XA_ID "=\"%s\""
                       " is implicitly allowed",
                       xml->name, display_id(xml));
 
         } else if (pcmk__check_acl(xml, NULL, pcmk__xf_acl_write)) {
             crm_trace("ACLs allow creation of <%s> with " PCMK_XA_ID "=\"%s\"",
                       xml->name, display_id(xml));
 
         } else if (check_top) {
             /* is_root=true should be impossible with check_top=true, but check
              * for sanity
              */
             bool is_root = (xmlDocGetRootElement(xml->doc) == xml);
             xml_doc_private_t *docpriv = xml->doc->_private;
 
             crm_trace("ACLs disallow creation of %s<%s> with "
                       PCMK_XA_ID "=\"%s\"",
                       (is_root? "root element " : ""), xml->name,
                       display_id(xml));
 
             // pcmk__xml_free() checks ACLs if enabled, which would fail
             pcmk__clear_xml_flags(docpriv, pcmk__xf_acl_enabled);
             pcmk__xml_free(xml);
 
             if (!is_root) {
                 // If root, the document was freed. Otherwise re-enable ACLs.
                 pcmk__set_xml_flags(docpriv, pcmk__xf_acl_enabled);
             }
             return;
 
         } else {
             crm_notice("ACLs would disallow creation of %s<%s> with "
                        PCMK_XA_ID "=\"%s\"",
                        ((xml == xmlDocGetRootElement(xml->doc))? "root element " : ""),
                        xml->name, display_id(xml));
         }
     }
 
     for (xmlNode *cIter = pcmk__xml_first_child(xml); cIter != NULL; ) {
         xmlNode *child = cIter;
         cIter = pcmk__xml_next(cIter); /* In case it is free'd */
         pcmk__apply_creation_acl(child, true);
     }
 }
 
 /*!
  * \brief Check whether or not an XML node is ACL-denied
  *
  * \param[in]  xml node to check
  *
  * \return true if XML node exists and is ACL-denied, false otherwise
  */
 bool
 xml_acl_denied(const xmlNode *xml)
 {
     if (xml && xml->doc && xml->doc->_private){
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         return pcmk_is_set(docpriv->flags, pcmk__xf_acl_denied);
     }
     return false;
 }
 
 void
 xml_acl_disable(xmlNode *xml)
 {
     if (xml_acl_enabled(xml)) {
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         /* Catch anything that was created but shouldn't have been */
         pcmk__apply_acl(xml);
         pcmk__apply_creation_acl(xml, false);
         pcmk__clear_xml_flags(docpriv, pcmk__xf_acl_enabled);
     }
 }
 
 /*!
  * \brief Check whether or not an XML node is ACL-enabled
  *
  * \param[in]  xml node to check
  *
  * \return true if XML node exists and is ACL-enabled, false otherwise
  */
 bool
 xml_acl_enabled(const xmlNode *xml)
 {
     if (xml && xml->doc && xml->doc->_private){
         xml_doc_private_t *docpriv = xml->doc->_private;
 
         return pcmk_is_set(docpriv->flags, pcmk__xf_acl_enabled);
     }
     return false;
 }
 
 /*!
  * \internal
  * \brief Deny access to an XML tree's document based on ACLs
  *
  * \param[in,out] xml        XML tree
  * \param[in]     attr_name  Name of attribute being accessed in \p xml (for
  *                           logging only)
  * \param[in]     prefix     Prefix describing ACL that denied access (for
  *                           logging only)
  * \param[in]     user       User accessing \p xml (for logging only)
- * \param[in]     mode       Access mode
+ * \param[in]     mode       Access mode (for logging only)
  */
 #define check_acl_deny(xml, attr_name, prefix, user, mode) do {             \
         xmlNode *tree = xml;                                                \
                                                                             \
         pcmk__xml_doc_set_flags(tree->doc, pcmk__xf_acl_denied);            \
         pcmk__if_tracing(                                                   \
             {                                                               \
                 GString *xpath = pcmk__element_xpath(tree);                 \
                                                                             \
                 if ((attr_name) != NULL) {                                  \
                     pcmk__g_strcat(xpath, "[@", attr_name, "]", NULL);      \
                 }                                                           \
                 qb_log_from_external_source(__func__, __FILE__,             \
                                             "%sACL denies user '%s' %s "    \
                                             "access to %s",                 \
                                             LOG_TRACE, __LINE__, 0 ,        \
                                             prefix, user,                   \
                                             acl_to_text(mode), xpath->str); \
                 g_string_free(xpath, TRUE);                                 \
             },                                                              \
             {}                                                              \
         );                                                                  \
     } while (false);
 
 bool
-pcmk__check_acl(xmlNode *xml, const char *attr_name,
-                enum xml_private_flags mode)
+pcmk__check_acl(xmlNode *xml, const char *attr_name, enum pcmk__xml_flags mode)
 {
     xml_doc_private_t *docpriv = NULL;
 
     pcmk__assert((xml != NULL) && (xml->doc->_private != NULL));
 
     if (!pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking)
         || !xml_acl_enabled(xml)) {
         return true;
     }
 
     docpriv = xml->doc->_private;
     if (docpriv->acls == NULL) {
         check_acl_deny(xml, attr_name, "Lack of ", docpriv->acl_user, mode);
         return false;
     }
 
     /* Walk the tree upwards looking for xml_acl_* flags
      * - Creating an attribute requires write permissions for the node
      * - Creating a child requires write permissions for the parent
      */
 
     if (attr_name != NULL) {
         xmlAttr *attr = xmlHasProp(xml, (const xmlChar *) attr_name);
 
         if ((attr != NULL) && (mode == pcmk__xf_acl_create)) {
             mode = pcmk__xf_acl_write;
         }
     }
 
     for (const xmlNode *parent = xml;
          (parent != NULL) && (parent->_private != NULL);
          parent = parent->parent) {
 
         const xml_node_private_t *nodepriv = parent->_private;
 
         if (test_acl_mode(nodepriv->flags, mode)) {
             return true;
         }
 
         if (pcmk_is_set(nodepriv->flags, pcmk__xf_acl_deny)) {
             const char *pfx = (parent != xml)? "Parent " : "";
 
             check_acl_deny(xml, attr_name, pfx, docpriv->acl_user, mode);
             return false;
         }
     }
 
     check_acl_deny(xml, attr_name, "Default ", docpriv->acl_user, mode);
     return false;
 }
 
 /*!
  * \brief Check whether ACLs are required for a given user
  *
  * \param[in]  User name to check
  *
  * \return true if the user requires ACLs, false otherwise
  */
 bool
 pcmk_acl_required(const char *user)
 {
     if (pcmk__str_empty(user)) {
         crm_trace("ACLs not required because no user set");
         return false;
 
     } else if (!strcmp(user, CRM_DAEMON_USER) || !strcmp(user, "root")) {
         crm_trace("ACLs not required for privileged user %s", user);
         return false;
     }
     crm_trace("ACLs required for %s", user);
     return true;
 }
 
 char *
 pcmk__uid2username(uid_t uid)
 {
     struct passwd *pwent = getpwuid(uid);
 
     if (pwent == NULL) {
         crm_perror(LOG_INFO, "Cannot get user details for user ID %d", uid);
         return NULL;
     }
     return pcmk__str_copy(pwent->pw_name);
 }
 
 /*!
  * \internal
  * \brief Set the ACL user field properly on an XML request
  *
  * Multiple user names are potentially involved in an XML request: the effective
  * user of the current process; the user name known from an IPC client
  * connection; and the user name obtained from the request itself, whether by
  * the current standard XML attribute name or an older legacy attribute name.
  * This function chooses the appropriate one that should be used for ACLs, sets
  * it in the request (using the standard attribute name, and the legacy name if
  * given), and returns it.
  *
  * \param[in,out] request    XML request to update
  * \param[in]     field      Alternate name for ACL user name XML attribute
  * \param[in]     peer_user  User name as known from IPC connection
  *
  * \return ACL user name actually used
  */
 const char *
 pcmk__update_acl_user(xmlNode *request, const char *field,
                       const char *peer_user)
 {
     static const char *effective_user = NULL;
     const char *requested_user = NULL;
     const char *user = NULL;
 
     if (effective_user == NULL) {
         effective_user = pcmk__uid2username(geteuid());
         if (effective_user == NULL) {
             effective_user = pcmk__str_copy("#unprivileged");
             crm_err("Unable to determine effective user, assuming unprivileged for ACLs");
         }
     }
 
     requested_user = crm_element_value(request, PCMK__XA_ACL_TARGET);
     if (requested_user == NULL) {
         /* Currently, different XML attribute names are used for the ACL user in
          * different contexts (PCMK__XA_ATTR_USER, PCMK__XA_CIB_USER, etc.).
          * The caller may specify that name as the field argument.
          *
          * @TODO Standardize on PCMK__XA_ACL_TARGET and eventually drop the
          * others once rolling upgrades from versions older than that are no
          * longer supported.
          */
         requested_user = crm_element_value(request, field);
     }
 
     if (!pcmk__is_privileged(effective_user)) {
         /* We're not running as a privileged user, set or overwrite any existing
          * value for PCMK__XA_ACL_TARGET
          */
         user = effective_user;
 
     } else if (peer_user == NULL && requested_user == NULL) {
         /* No user known or requested, use 'effective_user' and make sure one is
          * set for the request
          */
         user = effective_user;
 
     } else if (peer_user == NULL) {
         /* No user known, trusting 'requested_user' */
         user = requested_user;
 
     } else if (!pcmk__is_privileged(peer_user)) {
         /* The peer is not a privileged user, set or overwrite any existing
          * value for PCMK__XA_ACL_TARGET
          */
         user = peer_user;
 
     } else if (requested_user == NULL) {
         /* Even if we're privileged, make sure there is always a value set */
         user = peer_user;
 
     } else {
         /* Legal delegation to 'requested_user' */
         user = requested_user;
     }
 
     // This requires pointer comparison, not string comparison
     if (user != crm_element_value(request, PCMK__XA_ACL_TARGET)) {
         crm_xml_add(request, PCMK__XA_ACL_TARGET, user);
     }
 
     if (field != NULL && user != crm_element_value(request, field)) {
         crm_xml_add(request, field, user);
     }
 
     return requested_user;
 }
diff --git a/lib/common/crmcommon_private.h b/lib/common/crmcommon_private.h
index 8e1b453eb6..49c6c6eadd 100644
--- a/lib/common/crmcommon_private.h
+++ b/lib/common/crmcommon_private.h
@@ -1,488 +1,483 @@
 /*
  * Copyright 2018-2025 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 <libxml/xmlstring.h>           // xmlChar
 #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_internal.h>    // enum xml_private_flags
+#include <crm/common/xml_internal.h>    // enum pcmk__xml_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
 
 /*!
  * \internal
  * \brief Information about an XML node that was deleted
  *
  * When change tracking is enabled and we delete an XML node using
  * \c pcmk__xml_free(), we free it and add its path and position to a list in
  * its document's private data. This allows us to display changes, generate
  * patchsets, etc.
  *
  * Note that this does not happen when deleting an XML attribute using
  * \c pcmk__xa_remove(). In that case:
  * * If \c force is \c true, we remove the attribute without any tracking.
  * * If \c force is \c false, we mark the attribute as deleted but leave it in
  *   place until we commit changes.
  */
 typedef struct pcmk__deleted_xml_s {
     gchar *path;        //!< XPath expression identifying the deleted node
     int position;       //!< Position of the deleted node among its siblings
 } pcmk__deleted_xml_t;
 
 /*!
  * \internal
  * \brief Private data for an XML node
  */
 typedef struct xml_node_private_s {
     uint32_t check;         //!< Magic number for checking integrity
-    uint32_t flags;         //!< Group of <tt>enum xml_private_flags</tt>
+    uint32_t flags;         //!< Group of <tt>enum pcmk__xml_flags</tt>
+    xmlNode *match;         //!< Pointer to matching node (defined by caller)
 } xml_node_private_t;
 
 /*!
  * \internal
  * \brief Private data for an XML document
  */
 typedef struct xml_doc_private_s {
     uint32_t check;         //!< Magic number for checking integrity
-    uint32_t flags;         //!< Group of <tt>enum xml_private_flags</tt>
+    uint32_t flags;         //!< Group of <tt>enum pcmk__xml_flags</tt>
     char *acl_user;         //!< User affected by \c acls (for logging)
 
     //! ACLs to check requested changes against (list of \c xml_acl_t)
     GList *acls;
 
     //! XML nodes marked as deleted (list of \c pcmk__deleted_xml_t)
     GList *deleted_objs;
 } 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
 const char *pcmk__xml_element_type_text(xmlElementType type);
 
 G_GNUC_INTERNAL
 bool pcmk__xml_reset_node_flags(xmlNode *xml, void *user_data);
 
 G_GNUC_INTERNAL
 void pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags);
 
 G_GNUC_INTERNAL
 void pcmk__xml_new_private_data(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__xml_free_private_data(xmlNode *xml);
 
 G_GNUC_INTERNAL
 void pcmk__xml_free_node(xmlNode *xml);
 
 G_GNUC_INTERNAL
 xmlDoc *pcmk__xml_new_doc(void);
 
 G_GNUC_INTERNAL
-int pcmk__xml_position(const xmlNode *xml,
-                       enum xml_private_flags ignore_if_set);
+int pcmk__xml_position(const xmlNode *xml, enum pcmk__xml_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);
+bool pcmk__xc_matches(const xmlNode *comment1, const xmlNode *comment2);
 
 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, bool force);
 
 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/patchset.c b/lib/common/patchset.c
index 4f1fcbadf7..f4bda8dfc3 100644
--- a/lib/common/patchset.c
+++ b/lib/common/patchset.c
@@ -1,884 +1,885 @@
 /*
  * Copyright 2004-2025 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/tree.h>                // xmlNode
 
 #include <crm/crm.h>
 #include <crm/common/cib_internal.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>  // CRM_XML_LOG_BASE, etc.
 #include "crmcommon_private.h"
 
 /* Add changes for specified XML to patchset.
  * For patchset format, refer to diff schema.
  */
 static void
 add_xml_changes_to_patchset(xmlNode *xml, xmlNode *patchset)
 {
     xmlNode *cIter = NULL;
     xmlAttr *pIter = NULL;
     xmlNode *change = NULL;
     xml_node_private_t *nodepriv = xml->_private;
     const char *value = NULL;
 
     if (nodepriv == NULL) {
         /* Elements that shouldn't occur in a CIB don't have _private set. They
          * should be stripped out, ignored, or have an error thrown by any code
          * that processes their parent, so we ignore any changes to them.
          */
         return;
     }
 
     // If this XML node is new, just report that
     if (patchset && pcmk_is_set(nodepriv->flags, pcmk__xf_created)) {
         GString *xpath = pcmk__element_xpath(xml->parent);
 
         if (xpath != NULL) {
             int position = pcmk__xml_position(xml, pcmk__xf_deleted);
 
             change = pcmk__xe_create(patchset, PCMK_XE_CHANGE);
 
             crm_xml_add(change, PCMK_XA_OPERATION, PCMK_VALUE_CREATE);
             crm_xml_add(change, PCMK_XA_PATH, (const char *) xpath->str);
             crm_xml_add_int(change, PCMK_XE_POSITION, position);
             pcmk__xml_copy(change, xml);
             g_string_free(xpath, TRUE);
         }
 
         return;
     }
 
     // Check each of the XML node's attributes for changes
     for (pIter = pcmk__xe_first_attr(xml); pIter != NULL;
          pIter = pIter->next) {
         xmlNode *attr = NULL;
 
         nodepriv = pIter->_private;
         if (!pcmk_any_flags_set(nodepriv->flags, pcmk__xf_deleted|pcmk__xf_dirty)) {
             continue;
         }
 
         if (change == NULL) {
             GString *xpath = pcmk__element_xpath(xml);
 
             if (xpath != NULL) {
                 change = pcmk__xe_create(patchset, PCMK_XE_CHANGE);
 
                 crm_xml_add(change, PCMK_XA_OPERATION, PCMK_VALUE_MODIFY);
                 crm_xml_add(change, PCMK_XA_PATH, (const char *) xpath->str);
 
                 change = pcmk__xe_create(change, PCMK_XE_CHANGE_LIST);
                 g_string_free(xpath, TRUE);
             }
         }
 
         attr = pcmk__xe_create(change, PCMK_XE_CHANGE_ATTR);
 
         crm_xml_add(attr, PCMK_XA_NAME, (const char *) pIter->name);
         if (nodepriv->flags & pcmk__xf_deleted) {
             crm_xml_add(attr, PCMK_XA_OPERATION, "unset");
 
         } else {
             crm_xml_add(attr, PCMK_XA_OPERATION, "set");
 
             value = pcmk__xml_attr_value(pIter);
             crm_xml_add(attr, PCMK_XA_VALUE, value);
         }
     }
 
     if (change) {
         xmlNode *result = NULL;
 
         change = pcmk__xe_create(change->parent, PCMK_XE_CHANGE_RESULT);
         result = pcmk__xe_create(change, (const char *)xml->name);
 
         for (pIter = pcmk__xe_first_attr(xml); pIter != NULL;
              pIter = pIter->next) {
             nodepriv = pIter->_private;
             if (!pcmk_is_set(nodepriv->flags, pcmk__xf_deleted)) {
                 value = crm_element_value(xml, (const char *) pIter->name);
                 crm_xml_add(result, (const char *)pIter->name, value);
             }
         }
     }
 
     // Now recursively do the same for each child node of this node
     for (cIter = pcmk__xml_first_child(xml); cIter != NULL;
          cIter = pcmk__xml_next(cIter)) {
         add_xml_changes_to_patchset(cIter, patchset);
     }
 
     nodepriv = xml->_private;
     if (patchset && pcmk_is_set(nodepriv->flags, pcmk__xf_moved)) {
         GString *xpath = pcmk__element_xpath(xml);
 
         crm_trace("%s.%s moved to position %d",
                   xml->name, pcmk__xe_id(xml),
                   pcmk__xml_position(xml, pcmk__xf_skip));
 
         if (xpath != NULL) {
             change = pcmk__xe_create(patchset, PCMK_XE_CHANGE);
 
             crm_xml_add(change, PCMK_XA_OPERATION, PCMK_VALUE_MOVE);
             crm_xml_add(change, PCMK_XA_PATH, (const char *) xpath->str);
             crm_xml_add_int(change, PCMK_XE_POSITION,
                             pcmk__xml_position(xml, pcmk__xf_deleted));
             g_string_free(xpath, TRUE);
         }
     }
 }
 
 static bool
 is_config_change(xmlNode *xml)
 {
     GList *gIter = NULL;
     xml_node_private_t *nodepriv = NULL;
     xml_doc_private_t *docpriv;
     xmlNode *config = pcmk__xe_first_child(xml, PCMK_XE_CONFIGURATION, NULL,
                                            NULL);
 
     if (config) {
         nodepriv = config->_private;
     }
     if ((nodepriv != NULL) && pcmk_is_set(nodepriv->flags, pcmk__xf_dirty)) {
         return TRUE;
     }
 
     if ((xml->doc != NULL) && (xml->doc->_private != NULL)) {
         docpriv = xml->doc->_private;
         for (gIter = docpriv->deleted_objs; gIter; gIter = gIter->next) {
             pcmk__deleted_xml_t *deleted_obj = gIter->data;
 
             if (strstr(deleted_obj->path,
                        "/" PCMK_XE_CIB "/" PCMK_XE_CONFIGURATION) != NULL) {
                 return TRUE;
             }
         }
     }
     return FALSE;
 }
 
 static xmlNode *
 xml_create_patchset_v2(xmlNode *source, xmlNode *target)
 {
     int lpc = 0;
     GList *gIter = NULL;
     xml_doc_private_t *docpriv;
 
     xmlNode *v = NULL;
     xmlNode *version = NULL;
     xmlNode *patchset = NULL;
     const char *vfields[] = {
         PCMK_XA_ADMIN_EPOCH,
         PCMK_XA_EPOCH,
         PCMK_XA_NUM_UPDATES,
     };
 
     pcmk__assert(target != NULL);
 
     if (!pcmk__xml_doc_all_flags_set(target->doc, pcmk__xf_dirty)) {
         return NULL;
     }
 
     pcmk__assert(target->doc != NULL);
     docpriv = target->doc->_private;
 
     patchset = pcmk__xe_create(NULL, PCMK_XE_DIFF);
     crm_xml_add_int(patchset, PCMK_XA_FORMAT, 2);
 
     version = pcmk__xe_create(patchset, PCMK_XE_VERSION);
 
     v = pcmk__xe_create(version, PCMK_XE_SOURCE);
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         const char *value = crm_element_value(source, vfields[lpc]);
 
         if (value == NULL) {
             value = "1";
         }
         crm_xml_add(v, vfields[lpc], value);
     }
 
     v = pcmk__xe_create(version, PCMK_XE_TARGET);
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         const char *value = crm_element_value(target, vfields[lpc]);
 
         if (value == NULL) {
             value = "1";
         }
         crm_xml_add(v, vfields[lpc], value);
     }
 
     for (gIter = docpriv->deleted_objs; gIter; gIter = gIter->next) {
         pcmk__deleted_xml_t *deleted_obj = gIter->data;
         xmlNode *change = pcmk__xe_create(patchset, PCMK_XE_CHANGE);
 
         crm_xml_add(change, PCMK_XA_OPERATION, PCMK_VALUE_DELETE);
         crm_xml_add(change, PCMK_XA_PATH, deleted_obj->path);
         if (deleted_obj->position >= 0) {
             crm_xml_add_int(change, PCMK_XE_POSITION, deleted_obj->position);
         }
     }
 
     add_xml_changes_to_patchset(target, patchset);
     return patchset;
 }
 
 xmlNode *
 xml_create_patchset(int format, xmlNode *source, xmlNode *target,
                     bool *config_changed, bool manage_version)
 {
     bool local_config_changed = false;
 
     if (format == 0) {
         format = 2;
     }
     if (format != 2) {
         crm_err("Unknown patch format: %d", format);
         return NULL;
     }
 
     xml_acl_disable(target);
     if ((target == NULL)
         || !pcmk__xml_doc_all_flags_set(target->doc, pcmk__xf_dirty)) {
 
         crm_trace("No change %d", format);
         return NULL;
     }
 
     if (config_changed == NULL) {
         config_changed = &local_config_changed;
     }
     *config_changed = is_config_change(target);
 
     if (manage_version) {
         int counter = 0;
 
         if (*config_changed) {
             crm_xml_add(target, PCMK_XA_NUM_UPDATES, "0");
 
             crm_element_value_int(target, PCMK_XA_EPOCH, &counter);
             crm_xml_add_int(target, PCMK_XA_EPOCH, counter + 1);
 
         } else {
             crm_element_value_int(target, PCMK_XA_NUM_UPDATES, &counter);
             crm_xml_add_int(target, PCMK_XA_NUM_UPDATES, counter + 1);
         }
     }
 
     return xml_create_patchset_v2(source, target);
 }
 
 void
 patchset_process_digest(xmlNode *patch, xmlNode *source, xmlNode *target,
                         bool with_digest)
 {
     char *digest = NULL;
 
     if ((patch == NULL) || (source == NULL) || (target == NULL)
         || !with_digest) {
         return;
     }
 
-    /* We should always call xml_accept_changes() before calculating a digest.
-     * Otherwise, with an on-tracking dirty target, we could get a wrong digest.
+    /* We should always call pcmk__xml_commit_changes() before calculating a
+     * digest. Otherwise, with an on-tracking dirty target, we could get a wrong
+     * digest.
      */
     CRM_LOG_ASSERT(!pcmk__xml_doc_all_flags_set(target->doc, pcmk__xf_dirty));
 
     digest = pcmk__digest_xml(target, true);
 
     crm_xml_add(patch, PCMK__XA_DIGEST, digest);
     free(digest);
 
     return;
 }
 
 // Get CIB versions used for additions and deletions in a patchset
 bool
 xml_patch_versions(const xmlNode *patchset, int add[3], int del[3])
 {
     static const char *const vfields[] = {
         PCMK_XA_ADMIN_EPOCH,
         PCMK_XA_EPOCH,
         PCMK_XA_NUM_UPDATES,
     };
 
     const xmlNode *version = pcmk__xe_first_child(patchset, PCMK_XE_VERSION,
                                                   NULL, NULL);
     const xmlNode *source = pcmk__xe_first_child(version, PCMK_XE_SOURCE, NULL,
                                                  NULL);
     const xmlNode *target = pcmk__xe_first_child(version, PCMK_XE_TARGET, NULL,
                                                  NULL);
     int format = 1;
 
     crm_element_value_int(patchset, PCMK_XA_FORMAT, &format);
     if (format != 2) {
         crm_err("Unknown patch format: %d", format);
         return -EINVAL;
     }
 
     if (source != NULL) {
         for (int i = 0; i < PCMK__NELEM(vfields); i++) {
             crm_element_value_int(source, vfields[i], &(del[i]));
             crm_trace("Got %d for del[%s]", del[i], vfields[i]);
         }
     }
 
     if (target != NULL) {
         for (int i = 0; i < PCMK__NELEM(vfields); i++) {
             crm_element_value_int(target, vfields[i], &(add[i]));
             crm_trace("Got %d for add[%s]", add[i], vfields[i]);
         }
     }
     return pcmk_ok;
 }
 
 /*!
  * \internal
  * \brief Check whether patchset can be applied to current CIB
  *
  * \param[in] xml       Root of current CIB
  * \param[in] patchset  Patchset to check
  *
  * \return Standard Pacemaker return code
  */
 static int
 xml_patch_version_check(const xmlNode *xml, const xmlNode *patchset)
 {
     int lpc = 0;
     bool changed = FALSE;
 
     int this[] = { 0, 0, 0 };
     int add[] = { 0, 0, 0 };
     int del[] = { 0, 0, 0 };
 
     const char *vfields[] = {
         PCMK_XA_ADMIN_EPOCH,
         PCMK_XA_EPOCH,
         PCMK_XA_NUM_UPDATES,
     };
 
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         crm_element_value_int(xml, vfields[lpc], &(this[lpc]));
         crm_trace("Got %d for this[%s]", this[lpc], vfields[lpc]);
         if (this[lpc] < 0) {
             this[lpc] = 0;
         }
     }
 
     /* Set some defaults in case nothing is present */
     add[0] = this[0];
     add[1] = this[1];
     add[2] = this[2] + 1;
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         del[lpc] = this[lpc];
     }
 
     xml_patch_versions(patchset, add, del);
 
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         if (this[lpc] < del[lpc]) {
             crm_debug("Current %s is too low (%d.%d.%d < %d.%d.%d --> %d.%d.%d)",
                       vfields[lpc], this[0], this[1], this[2],
                       del[0], del[1], del[2], add[0], add[1], add[2]);
             return pcmk_rc_diff_resync;
 
         } else if (this[lpc] > del[lpc]) {
             crm_info("Current %s is too high (%d.%d.%d > %d.%d.%d --> %d.%d.%d) %p",
                      vfields[lpc], this[0], this[1], this[2],
                      del[0], del[1], del[2], add[0], add[1], add[2], patchset);
             crm_log_xml_info(patchset, "OldPatch");
             return pcmk_rc_old_data;
         }
     }
 
     for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
         if (add[lpc] > del[lpc]) {
             changed = TRUE;
         }
     }
 
     if (!changed) {
         crm_notice("Versions did not change in patch %d.%d.%d",
                    add[0], add[1], add[2]);
         return pcmk_rc_old_data;
     }
 
     crm_debug("Can apply patch %d.%d.%d to %d.%d.%d",
               add[0], add[1], add[2], this[0], this[1], this[2]);
     return pcmk_rc_ok;
 }
 
 // Return first child matching element name and optionally id or position
 static xmlNode *
 first_matching_xml_child(const xmlNode *parent, const char *name,
                          const char *id, int position)
 {
     xmlNode *cIter = NULL;
 
     for (cIter = pcmk__xml_first_child(parent); cIter != NULL;
          cIter = pcmk__xml_next(cIter)) {
         if (strcmp((const char *) cIter->name, name) != 0) {
             continue;
         } else if (id) {
             const char *cid = pcmk__xe_id(cIter);
 
             if ((cid == NULL) || (strcmp(cid, id) != 0)) {
                 continue;
             }
         }
 
         // "position" makes sense only for XML comments for now
         if ((cIter->type == XML_COMMENT_NODE)
             && (position >= 0)
             && (pcmk__xml_position(cIter, pcmk__xf_skip) != position)) {
             continue;
         }
 
         return cIter;
     }
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Simplified, more efficient alternative to pcmk__xpath_find_one()
  *
  * \param[in] top              Root of XML to search
  * \param[in] key              Search xpath
  * \param[in] target_position  If deleting, where to delete
  *
  * \return XML child matching xpath if found, NULL otherwise
  *
  * \note This only works on simplified xpaths found in v2 patchset diffs,
  *       i.e. the only allowed search predicate is [@id='XXX'].
  */
 static xmlNode *
 search_v2_xpath(const xmlNode *top, const char *key, int target_position)
 {
     xmlNode *target = (xmlNode *) top->doc;
     const char *current = key;
     char *section;
     char *remainder;
     char *id;
     char *tag;
     char *path = NULL;
     int rc;
     size_t key_len;
 
     CRM_CHECK(key != NULL, return NULL);
     key_len = strlen(key);
 
     /* These are scanned from key after a slash, so they can't be bigger
      * than key_len - 1 characters plus a null terminator.
      */
 
     remainder = pcmk__assert_alloc(key_len, sizeof(char));
     section = pcmk__assert_alloc(key_len, sizeof(char));
     id = pcmk__assert_alloc(key_len, sizeof(char));
     tag = pcmk__assert_alloc(key_len, sizeof(char));
 
     do {
         // Look for /NEXT_COMPONENT/REMAINING_COMPONENTS
         rc = sscanf(current, "/%[^/]%s", section, remainder);
         if (rc > 0) {
             // Separate FIRST_COMPONENT into TAG[@id='ID']
             int f = sscanf(section, "%[^[][@" PCMK_XA_ID "='%[^']", tag, id);
             int current_position = -1;
 
             /* The target position is for the final component tag, so only use
              * it if there is nothing left to search after this component.
              */
             if ((rc == 1) && (target_position >= 0)) {
                 current_position = target_position;
             }
 
             switch (f) {
                 case 1:
                     target = first_matching_xml_child(target, tag, NULL,
                                                       current_position);
                     break;
                 case 2:
                     target = first_matching_xml_child(target, tag, id,
                                                       current_position);
                     break;
                 default:
                     // This should not be possible
                     target = NULL;
                     break;
             }
             current = remainder;
         }
 
     // Continue if something remains to search, and we've matched so far
     } while ((rc == 2) && target);
 
     if (target) {
         crm_trace("Found %s for %s",
                   (path = (char *) xmlGetNodePath(target)), key);
         free(path);
     } else {
         crm_debug("No match for %s", key);
     }
 
     free(remainder);
     free(section);
     free(tag);
     free(id);
     return target;
 }
 
 typedef struct xml_change_obj_s {
     const xmlNode *change;
     xmlNode *match;
 } xml_change_obj_t;
 
 static gint
 sort_change_obj_by_position(gconstpointer a, gconstpointer b)
 {
     const xml_change_obj_t *change_obj_a = a;
     const xml_change_obj_t *change_obj_b = b;
     int position_a = -1;
     int position_b = -1;
 
     crm_element_value_int(change_obj_a->change, PCMK_XE_POSITION, &position_a);
     crm_element_value_int(change_obj_b->change, PCMK_XE_POSITION, &position_b);
 
     if (position_a < position_b) {
         return -1;
 
     } else if (position_a > position_b) {
         return 1;
     }
 
     return 0;
 }
 
 /*!
  * \internal
  * \brief Apply a version 2 patchset to an XML node
  *
  * \param[in,out] xml       XML to apply patchset to
  * \param[in]     patchset  Patchset to apply
  *
  * \return Standard Pacemaker return code
  */
 static int
 apply_v2_patchset(xmlNode *xml, const xmlNode *patchset)
 {
     int rc = pcmk_rc_ok;
     const xmlNode *change = NULL;
     GList *change_objs = NULL;
     GList *gIter = NULL;
 
     for (change = pcmk__xml_first_child(patchset); change != NULL;
          change = pcmk__xml_next(change)) {
         xmlNode *match = NULL;
         const char *op = crm_element_value(change, PCMK_XA_OPERATION);
         const char *xpath = crm_element_value(change, PCMK_XA_PATH);
         int position = -1;
 
         if (op == NULL) {
             continue;
         }
 
         crm_trace("Processing %s %s", change->name, op);
 
         /* PCMK_VALUE_DELETE changes for XML comments are generated with
          * PCMK_XE_POSITION
          */
         if (strcmp(op, PCMK_VALUE_DELETE) == 0) {
             crm_element_value_int(change, PCMK_XE_POSITION, &position);
         }
         match = search_v2_xpath(xml, xpath, position);
         crm_trace("Performing %s on %s with %p", op, xpath, match);
 
         if ((match == NULL) && (strcmp(op, PCMK_VALUE_DELETE) == 0)) {
             crm_debug("No %s match for %s in %p", op, xpath, xml->doc);
             continue;
 
         } else if (match == NULL) {
             crm_err("No %s match for %s in %p", op, xpath, xml->doc);
             rc = pcmk_rc_diff_failed;
             continue;
 
         } else if (pcmk__str_any_of(op,
                                     PCMK_VALUE_CREATE, PCMK_VALUE_MOVE, NULL)) {
             // Delay the adding of a PCMK_VALUE_CREATE object
             xml_change_obj_t *change_obj =
                 pcmk__assert_alloc(1, sizeof(xml_change_obj_t));
 
             change_obj->change = change;
             change_obj->match = match;
 
             change_objs = g_list_append(change_objs, change_obj);
 
             if (strcmp(op, PCMK_VALUE_MOVE) == 0) {
                 // Temporarily put the PCMK_VALUE_MOVE object after the last sibling
                 if ((match->parent != NULL) && (match->parent->last != NULL)) {
                     xmlAddNextSibling(match->parent->last, match);
                 }
             }
 
         } else if (strcmp(op, PCMK_VALUE_DELETE) == 0) {
             pcmk__xml_free(match);
 
         } else if (strcmp(op, PCMK_VALUE_MODIFY) == 0) {
             const xmlNode *child = pcmk__xe_first_child(change,
                                                         PCMK_XE_CHANGE_RESULT,
                                                         NULL, NULL);
             const xmlNode *attrs = pcmk__xml_first_child(child);
 
             if (attrs == NULL) {
                 rc = ENOMSG;
                 continue;
             }
 
             // Remove all attributes
             pcmk__xe_remove_matching_attrs(match, false, NULL, NULL);
 
             for (xmlAttrPtr pIter = pcmk__xe_first_attr(attrs); pIter != NULL;
                  pIter = pIter->next) {
                 const char *name = (const char *) pIter->name;
                 const char *value = pcmk__xml_attr_value(pIter);
 
                 crm_xml_add(match, name, value);
             }
 
         } else {
             crm_err("Unknown operation: %s", op);
             rc = pcmk_rc_diff_failed;
         }
     }
 
     // Changes should be generated in the right order. Double checking.
     change_objs = g_list_sort(change_objs, sort_change_obj_by_position);
 
     for (gIter = change_objs; gIter; gIter = gIter->next) {
         xml_change_obj_t *change_obj = gIter->data;
         xmlNode *match = change_obj->match;
         const char *op = NULL;
         const char *xpath = NULL;
 
         change = change_obj->change;
 
         op = crm_element_value(change, PCMK_XA_OPERATION);
         xpath = crm_element_value(change, PCMK_XA_PATH);
 
         crm_trace("Continue performing %s on %s with %p", op, xpath, match);
 
         if (strcmp(op, PCMK_VALUE_CREATE) == 0) {
             int position = 0;
             xmlNode *child = NULL;
             xmlNode *match_child = NULL;
 
             match_child = match->children;
             crm_element_value_int(change, PCMK_XE_POSITION, &position);
 
             while ((match_child != NULL)
                    && (position != pcmk__xml_position(match_child, pcmk__xf_skip))) {
                 match_child = match_child->next;
             }
 
             child = pcmk__xml_copy(match, change->children);
 
             if (match_child != NULL) {
                 crm_trace("Adding %s at position %d", child->name, position);
                 xmlAddPrevSibling(match_child, child);
 
             } else {
                 crm_trace("Adding %s at position %d (end)",
                           child->name, position);
             }
 
         } else if (strcmp(op, PCMK_VALUE_MOVE) == 0) {
             int position = 0;
 
             crm_element_value_int(change, PCMK_XE_POSITION, &position);
             if (position != pcmk__xml_position(match, pcmk__xf_skip)) {
                 xmlNode *match_child = NULL;
                 int p = position;
 
                 if (p > pcmk__xml_position(match, pcmk__xf_skip)) {
                     p++; // Skip ourselves
                 }
 
                 pcmk__assert(match->parent != NULL);
                 match_child = match->parent->children;
 
                 while ((match_child != NULL)
                        && (p != pcmk__xml_position(match_child, pcmk__xf_skip))) {
                     match_child = match_child->next;
                 }
 
                 crm_trace("Moving %s to position %d (was %d, prev %p, %s %p)",
                           match->name, position,
                           pcmk__xml_position(match, pcmk__xf_skip),
                           match->prev, (match_child? "next":"last"),
                           (match_child? match_child : match->parent->last));
 
                 if (match_child) {
                     xmlAddPrevSibling(match_child, match);
 
                 } else {
                     pcmk__assert(match->parent->last != NULL);
                     xmlAddNextSibling(match->parent->last, match);
                 }
 
             } else {
                 crm_trace("%s is already in position %d",
                           match->name, position);
             }
 
             if (position != pcmk__xml_position(match, pcmk__xf_skip)) {
                 crm_err("Moved %s.%s to position %d instead of %d (%p)",
                         match->name, pcmk__xe_id(match),
                         pcmk__xml_position(match, pcmk__xf_skip),
                         position, match->prev);
                 rc = pcmk_rc_diff_failed;
             }
         }
     }
 
     g_list_free_full(change_objs, free);
     return rc;
 }
 
 int
 xml_apply_patchset(xmlNode *xml, xmlNode *patchset, bool check_version)
 {
     int format = 1;
     int rc = pcmk_ok;
     xmlNode *old = NULL;
     const char *digest = NULL;
 
     if (patchset == NULL) {
         return rc;
     }
 
     pcmk__log_xml_patchset(LOG_TRACE, patchset);
 
     if (check_version) {
         rc = pcmk_rc2legacy(xml_patch_version_check(xml, patchset));
         if (rc != pcmk_ok) {
             return rc;
         }
     }
 
     digest = crm_element_value(patchset, PCMK__XA_DIGEST);
     if (digest != NULL) {
         /* Make original XML available for logging in case result doesn't have
          * expected digest
          */
         pcmk__if_tracing(old = pcmk__xml_copy(NULL, xml), {});
     }
 
     if (rc == pcmk_ok) {
         crm_element_value_int(patchset, PCMK_XA_FORMAT, &format);
 
         if (format != 2) {
             crm_err("Unknown patch format: %d", format);
             rc = -EINVAL;
 
         } else {
             rc = pcmk_rc2legacy(apply_v2_patchset(xml, patchset));
         }
     }
 
     if ((rc == pcmk_ok) && (digest != NULL)) {
         char *new_digest = NULL;
 
         new_digest = pcmk__digest_xml(xml, true);
         if (!pcmk__str_eq(new_digest, digest, pcmk__str_casei)) {
             crm_info("v%d digest mis-match: expected %s, calculated %s",
                      format, digest, new_digest);
             rc = -pcmk_err_diff_failed;
             pcmk__if_tracing(
                 {
                     save_xml_to_file(old, "PatchDigest:input", NULL);
                     save_xml_to_file(xml, "PatchDigest:result", NULL);
                     save_xml_to_file(patchset, "PatchDigest:diff", NULL);
                 },
                 {}
             );
 
         } else {
             crm_trace("v%d digest matched: expected %s, calculated %s",
                       format, digest, new_digest);
         }
         free(new_digest);
     }
     pcmk__xml_free(old);
     return rc;
 }
 
 bool
 pcmk__cib_element_in_patchset(const xmlNode *patchset, const char *element)
 {
     const char *element_xpath = pcmk__cib_abs_xpath_for(element);
     const char *parent_xpath = pcmk_cib_parent_name_for(element);
     char *element_regex = NULL;
     bool rc = false;
     int format = 1;
 
     pcmk__assert(patchset != NULL);
 
     crm_element_value_int(patchset, PCMK_XA_FORMAT, &format);
     if (format != 2) {
         crm_warn("Unknown patch format: %d", format);
         return false;
     }
 
     CRM_CHECK(element_xpath != NULL, return false); // Unsupported element
 
     /* Matches if and only if element_xpath is part of a changed path
      * (supported values for element never contain XML IDs with schema
      * validation enabled)
      *
      * @TODO Use POSIX word boundary instead of (/|$), if it works:
      * https://www.regular-expressions.info/wordboundaries.html.
      */
     element_regex = crm_strdup_printf("^%s(/|$)", element_xpath);
 
     for (const xmlNode *change = pcmk__xe_first_child(patchset, PCMK_XE_CHANGE,
                                                       NULL, NULL);
          change != NULL; change = pcmk__xe_next(change, PCMK_XE_CHANGE)) {
 
         const char *op = crm_element_value(change, PCMK_XA_OPERATION);
         const char *diff_xpath = crm_element_value(change, PCMK_XA_PATH);
 
         if (pcmk__str_eq(diff_xpath, element_regex, pcmk__str_regex)) {
             // Change to an existing element
             rc = true;
             break;
         }
 
         if (pcmk__str_eq(op, PCMK_VALUE_CREATE, pcmk__str_none)
             && pcmk__str_eq(diff_xpath, parent_xpath, pcmk__str_none)
             && pcmk__xe_is(pcmk__xe_first_child(change, NULL, NULL, NULL),
                                                 element)) {
             // Newly added element
             rc = true;
             break;
         }
     }
 
     free(element_regex);
     return rc;
 }
diff --git a/lib/common/tests/patchset/pcmk__cib_element_in_patchset_test.c b/lib/common/tests/patchset/pcmk__cib_element_in_patchset_test.c
index 79afff35a7..dfe9e859f9 100644
--- a/lib/common/tests/patchset/pcmk__cib_element_in_patchset_test.c
+++ b/lib/common/tests/patchset/pcmk__cib_element_in_patchset_test.c
@@ -1,243 +1,243 @@
 /*
- * Copyright 2024 the Pacemaker project contributors
+ * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 
 #define ORIG_CIB                                                            \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-1\"/>"                \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 static void
 assert_in_patchset(const char *source_s, const char *target_s,
                    const char *element, bool reference)
 {
     xmlNode *source = pcmk__xml_parse(source_s);
     xmlNode *target = pcmk__xml_parse(target_s);
     xmlNode *patchset = NULL;
 
-    xml_track_changes(target, NULL, NULL, false);
-    xml_calculate_significant_changes(source, target);
+    pcmk__xml_doc_set_flags(target->doc, pcmk__xf_ignore_attr_pos);
+    pcmk__xml_mark_changes(source, target);
     patchset = xml_create_patchset(2, source, target, NULL, false);
 
     if (reference) {
         assert_true(pcmk__cib_element_in_patchset(patchset, element));
     } else {
         assert_false(pcmk__cib_element_in_patchset(patchset, element));
     }
 
     pcmk__xml_free(source);
     pcmk__xml_free(target);
     pcmk__xml_free(patchset);
 }
 
 static void
 null_patchset_asserts(void **state)
 {
     pcmk__assert_asserts(pcmk__cib_element_in_patchset(NULL, NULL));
     pcmk__assert_asserts(pcmk__cib_element_in_patchset(NULL, PCMK_XE_NODES));
 }
 
 // PCMK_XE_ALERTS element has been created relative to ORIG_CIB
 #define CREATE_CIB                                                          \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-1\"/>"                \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
         "<" PCMK_XE_ALERTS "/>"                                             \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 static void
 create_op(void **state)
 {
     // Requested element was created
     assert_in_patchset(ORIG_CIB, CREATE_CIB, PCMK_XE_ALERTS, true);
 
     // Requested element's descendant was created
     assert_in_patchset(ORIG_CIB, CREATE_CIB, PCMK_XE_CONFIGURATION, true);
     assert_in_patchset(ORIG_CIB, CREATE_CIB, NULL, true);
 
     // Requested element was not changed
     assert_in_patchset(ORIG_CIB, CREATE_CIB, PCMK_XE_STATUS, false);
 }
 
 static void
 delete_op(void **state)
 {
     // Requested element was deleted
     assert_in_patchset(CREATE_CIB, ORIG_CIB, PCMK_XE_ALERTS, true);
 
     // Requested element's descendant was deleted
     assert_in_patchset(CREATE_CIB, ORIG_CIB, PCMK_XE_CONFIGURATION, true);
     assert_in_patchset(CREATE_CIB, ORIG_CIB, NULL, true);
 
     // Requested element was not changed
     assert_in_patchset(CREATE_CIB, ORIG_CIB, PCMK_XE_STATUS, false);
 }
 
 // PCMK_XE_CIB XML attribute was added relative to ORIG_CIB
 #define MODIFY_ADD_CIB                                                      \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\""                        \
                     " " PCMK_XA_CRM_FEATURE_SET "=\"3.19.7\">"              \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-1\"/>"                \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 // PCMK_XE_CIB XML attribute was updated relative to ORIG_CIB
 #define MODIFY_UPDATE_CIB                                                   \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"1\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-1\"/>"                \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 // PCMK_XE_NODE XML attribute was added relative to ORIG_CIB
 #define MODIFY_ADD_NODE_CIB                                                 \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-1\""                  \
                            " " PCMK_XA_TYPE "=\"member\"/>"                 \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 // PCMK_XE_NODE XML attribute was updated relative to ORIG_CIB
 #define MODIFY_UPDATE_NODE_CIB                                              \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES ">"                                               \
           "<" PCMK_XE_NODE " " PCMK_XA_ID "=\"1\""                          \
                            " " PCMK_XA_UNAME "=\"node-2\"/>"                \
         "</" PCMK_XE_NODES ">"                                              \
         "<" PCMK_XE_RESOURCES "/>"                                          \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 static void
 modify_op(void **state)
 {
     // Requested element was modified (attribute added)
     assert_in_patchset(ORIG_CIB, MODIFY_ADD_CIB, PCMK_XE_CIB, true);
 
     // Requested element was modified (attribute updated)
     assert_in_patchset(ORIG_CIB, MODIFY_UPDATE_CIB, PCMK_XE_CIB, true);
 
     // Requested element was modified (attribute deleted)
     assert_in_patchset(MODIFY_ADD_CIB, ORIG_CIB, PCMK_XE_CIB, true);
 
     // Requested element's descendant was modified (attribute added)
     assert_in_patchset(ORIG_CIB, MODIFY_ADD_NODE_CIB, PCMK_XE_CIB, true);
     assert_in_patchset(ORIG_CIB, MODIFY_ADD_NODE_CIB, NULL, true);
 
     // Requested element's descendant was modified (attribute updated)
     assert_in_patchset(ORIG_CIB, MODIFY_UPDATE_NODE_CIB, PCMK_XE_CIB, true);
     assert_in_patchset(ORIG_CIB, MODIFY_UPDATE_NODE_CIB, NULL, true);
 
     // Requested element's descenant was modified (attribute deleted)
     assert_in_patchset(MODIFY_ADD_NODE_CIB, ORIG_CIB, PCMK_XE_CIB, true);
     assert_in_patchset(MODIFY_ADD_NODE_CIB, ORIG_CIB, NULL, true);
 
     // Requested element was not changed
     assert_in_patchset(ORIG_CIB, MODIFY_ADD_CIB, PCMK_XE_STATUS, false);
     assert_in_patchset(ORIG_CIB, MODIFY_UPDATE_CIB, PCMK_XE_STATUS, false);
     assert_in_patchset(ORIG_CIB, MODIFY_ADD_NODE_CIB, PCMK_XE_STATUS, false);
     assert_in_patchset(ORIG_CIB, MODIFY_UPDATE_NODE_CIB, PCMK_XE_STATUS, false);
 }
 
 // PCMK_XE_RESOURCES and PCMK_XE_CONSTRAINTS are swapped relative to ORIG_CIB
 #define MOVE_CIB                                                            \
     "<" PCMK_XE_CIB " " PCMK_XA_ADMIN_EPOCH "=\"0\""                        \
                     " " PCMK_XA_EPOCH "=\"0\""                              \
                     " " PCMK_XA_NUM_UPDATES "=\"0\">"                       \
       "<" PCMK_XE_CONFIGURATION ">"                                         \
         "<" PCMK_XE_CRM_CONFIG "/>"                                         \
         "<" PCMK_XE_NODES "/>"                                              \
         "<" PCMK_XE_CONSTRAINTS "/>"                                        \
         "<" PCMK_XE_RESOURCES "/>"                                          \
       "</" PCMK_XE_CONFIGURATION ">"                                        \
       "<" PCMK_XE_STATUS "/>"                                               \
     "</" PCMK_XE_CIB ">"
 
 static void
 move_op(void **state)
 {
     // Requested element was moved
     assert_in_patchset(ORIG_CIB, MOVE_CIB, PCMK_XE_RESOURCES, true);
     assert_in_patchset(ORIG_CIB, MOVE_CIB, PCMK_XE_CONSTRAINTS, true);
 
     // Requested element's descendant was moved
     assert_in_patchset(ORIG_CIB, MOVE_CIB, PCMK_XE_CONFIGURATION, true);
     assert_in_patchset(ORIG_CIB, MOVE_CIB, NULL, true);
 
     // Requested element was not changed
     assert_in_patchset(ORIG_CIB, MOVE_CIB, PCMK_XE_STATUS, false);
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(null_patchset_asserts),
                 cmocka_unit_test(create_op),
                 cmocka_unit_test(delete_op),
                 cmocka_unit_test(modify_op),
                 cmocka_unit_test(move_op))
diff --git a/lib/common/tests/xml/pcmk__xml_new_doc_test.c b/lib/common/tests/xml/pcmk__xml_new_doc_test.c
index 762aec4007..28b99b77e2 100644
--- a/lib/common/tests/xml/pcmk__xml_new_doc_test.c
+++ b/lib/common/tests/xml/pcmk__xml_new_doc_test.c
@@ -1,39 +1,38 @@
 /*
- * Copyright 2024 the Pacemaker project contributors
+ * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include "crmcommon_private.h"
 
 /* This tests new_private_data() indirectly for document nodes. Testing
  * free_private_data() would be much less straightforward and is not worth the
  * hassle.
  */
 
 static void
 create_document_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
 
     assert_non_null(doc);
     assert_int_equal(doc->type, XML_DOCUMENT_NODE);
 
     docpriv = doc->_private;
     assert_non_null(docpriv);
     assert_int_equal(docpriv->check, PCMK__XML_DOC_PRIVATE_MAGIC);
-    assert_true(pcmk_all_flags_set(docpriv->flags,
-                                   pcmk__xf_dirty|pcmk__xf_created));
+    assert_int_equal(docpriv->flags, pcmk__xf_none);
 
     pcmk__xml_free_doc(doc);
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(create_document_node))
diff --git a/lib/common/tests/xml_comment/pcmk__xc_create_test.c b/lib/common/tests/xml_comment/pcmk__xc_create_test.c
index 4e25adc130..a16a7da6f8 100644
--- a/lib/common/tests/xml_comment/pcmk__xc_create_test.c
+++ b/lib/common/tests/xml_comment/pcmk__xc_create_test.c
@@ -1,77 +1,71 @@
 /*
- * Copyright 2024 the Pacemaker project contributors
+ * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include "crmcommon_private.h"
 
 /* This tests new_private_data() indirectly for comment nodes. Testing
  * free_private_data() would be much less straightforward and is not worth the
  * hassle.
  */
 
 static void
-assert_comment(xmlDoc *doc, const char *content)
+assert_comment(const char *content)
 {
+    xmlDoc *doc = pcmk__xml_new_doc();
+    xml_doc_private_t *docpriv = doc->_private;
     xmlNode *node = NULL;
     xml_node_private_t *nodepriv = NULL;
-    xml_doc_private_t *docpriv = doc->_private;
 
-    // Also clears existing doc flags
-    xml_track_changes((xmlNode *) doc, NULL, NULL, false);
+    pcmk__xml_doc_set_flags(doc, pcmk__xf_tracking);
 
     node = pcmk__xc_create(doc, content);
     assert_non_null(node);
     assert_int_equal(node->type, XML_COMMENT_NODE);
     assert_ptr_equal(node->doc, doc);
 
     if (content == NULL) {
         assert_null(node->content);
     } else {
         assert_non_null(node->content);
         assert_string_equal((const char *) node->content, content);
     }
 
     nodepriv = node->_private;
     assert_non_null(nodepriv);
     assert_int_equal(nodepriv->check, PCMK__XML_NODE_PRIVATE_MAGIC);
     assert_true(pcmk_all_flags_set(nodepriv->flags,
                                    pcmk__xf_dirty|pcmk__xf_created));
 
     assert_true(pcmk_is_set(docpriv->flags, pcmk__xf_dirty));
 
     pcmk__xml_free(node);
+    pcmk__xml_free_doc(doc);
 }
 
 static void
 null_doc(void **state)
 {
     pcmk__assert_asserts(pcmk__xc_create(NULL, NULL));
     pcmk__assert_asserts(pcmk__xc_create(NULL, "some content"));
 }
 
 static void
 with_doc(void **state)
 {
-    xmlDoc *doc = pcmk__xml_new_doc();
-
-    assert_non_null(doc);
-    assert_non_null(doc->_private);
-
-    assert_comment(doc, NULL);
-    assert_comment(doc, "some content");
-
-    pcmk__xml_free_doc(doc);
+    assert_comment(NULL);
+    assert_comment("some content");
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(null_doc),
                 cmocka_unit_test(with_doc));
diff --git a/lib/common/tests/xml_element/pcmk__xe_sort_attrs_test.c b/lib/common/tests/xml_element/pcmk__xe_sort_attrs_test.c
index b5265b1d49..106d5bc34a 100644
--- a/lib/common/tests/xml_element/pcmk__xe_sort_attrs_test.c
+++ b/lib/common/tests/xml_element/pcmk__xe_sort_attrs_test.c
@@ -1,203 +1,203 @@
 /*
  * Copyright 2024-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include <glib.h>                           // GHashTable, etc.
 #include <libxml/tree.h>                    // xmlNode
 #include <libxml/xmlstring.h>               // xmlChar
 
 #include "crmcommon_private.h"              // xml_node_private_t
 
 /*!
  * \internal
  * \brief Sort an XML element's attributes and compare against a reference
  *
  * This also verifies that any flags set on the original attributes are
  * preserved.
  *
  * \param[in,out] test_xml       XML whose attributes to sort
  * \param[in]     reference_xml  XML whose attribute order to compare against
  *                               (attributes must have the same values as in
  *                               \p test_xml)
  */
 static void
 assert_order(xmlNode *test_xml, const xmlNode *reference_xml)
 {
     GHashTable *attr_flags = pcmk__strkey_table(free, NULL);
     xmlAttr *test_attr = NULL;
     xmlAttr *ref_attr = NULL;
 
     // Save original flags
     for (xmlAttr *attr = pcmk__xe_first_attr(test_xml); attr != NULL;
          attr = attr->next) {
 
         xml_node_private_t *nodepriv = attr->_private;
         uint32_t flags = (nodepriv != NULL)? nodepriv->flags : pcmk__xf_none;
 
         g_hash_table_insert(attr_flags,
                             pcmk__str_copy((const char *) attr->name),
                             GUINT_TO_POINTER((guint) flags));
     }
 
     pcmk__xe_sort_attrs(test_xml);
 
     test_attr = pcmk__xe_first_attr(test_xml);
     ref_attr = pcmk__xe_first_attr(reference_xml);
 
     for (; (test_attr != NULL) && (ref_attr != NULL);
          test_attr = test_attr->next, ref_attr = ref_attr->next) {
 
         const char *test_name = (const char *) test_attr->name;
         xml_node_private_t *nodepriv = test_attr->_private;
         uint32_t flags = (nodepriv != NULL)? nodepriv->flags : pcmk__xf_none;
 
         gpointer old_flags_ptr = g_hash_table_lookup(attr_flags, test_name);
         uint32_t old_flags = pcmk__xf_none;
 
         if (old_flags_ptr != NULL) {
             old_flags = GPOINTER_TO_UINT(old_flags_ptr);
         }
 
         // Flags must not change
         assert_true(flags == old_flags);
 
         // Attributes must be in expected order with expected values
         assert_string_equal(test_name, (const char *) ref_attr->name);
         assert_string_equal(pcmk__xml_attr_value(test_attr),
                             pcmk__xml_attr_value(ref_attr));
     }
 
     // Attribute lists must be the same length
     assert_null(test_attr);
     assert_null(ref_attr);
 
     g_hash_table_destroy(attr_flags);
 }
 
 static void
 null_arg(void **state)
 {
     // Ensure it doesn't crash
     pcmk__xe_sort_attrs(NULL);
 }
 
 static void
 nothing_to_sort(void **state)
 {
     xmlNode *test_xml = pcmk__xe_create(NULL, "test");
     xmlNode *reference_xml = NULL;
 
     // No attributes
     reference_xml = pcmk__xml_copy(NULL, test_xml);
     assert_order(test_xml, reference_xml);
     pcmk__xml_free(reference_xml);
 
     // Only one attribute
     crm_xml_add(test_xml, "name", "value");
     reference_xml = pcmk__xml_copy(NULL, test_xml);
     assert_order(test_xml, reference_xml);
     pcmk__xml_free(reference_xml);
 
     pcmk__xml_free(test_xml);
 }
 
 static void
 already_sorted(void **state)
 {
     xmlNode *test_xml = pcmk__xe_create(NULL, "test");
     xmlNode *reference_xml = pcmk__xe_create(NULL, "test");
 
     xmlAttr *attr = NULL;
 
     crm_xml_add(test_xml, "admin", "john");
     crm_xml_add(test_xml, "dummy", "value");
     crm_xml_add(test_xml, "location", "usa");
 
     // Set flags in test_xml's attributes for testing flag preservation
     attr = xmlHasProp(test_xml, (const xmlChar *) "admin");
     if (attr != NULL) {
         xml_node_private_t *nodepriv = attr->_private;
 
         if (nodepriv != NULL) {
             pcmk__clear_xml_flags(nodepriv, pcmk__xf_created|pcmk__xf_dirty);
         }
     }
 
     attr = xmlHasProp(test_xml, (const xmlChar *) "location");
     if (attr != NULL) {
         xml_node_private_t *nodepriv = attr->_private;
 
         if (nodepriv != NULL) {
-            pcmk__set_xml_flags(nodepriv, pcmk__xf_lazy);
+            pcmk__set_xml_flags(nodepriv, pcmk__xf_ignore_attr_pos);
         }
     }
 
     pcmk__xe_set_props(reference_xml,
                        "admin", "john",
                        "dummy", "value",
                        "location", "usa",
                        NULL);
 
     assert_order(test_xml, reference_xml);
 
     pcmk__xml_free(test_xml);
     pcmk__xml_free(reference_xml);
 }
 
 static void
 need_sort(void **state)
 {
     xmlNode *test_xml = pcmk__xe_create(NULL, "test");
     xmlNode *reference_xml = pcmk__xe_create(NULL, "test");
 
     xmlAttr *attr = NULL;
 
     crm_xml_add(test_xml, "location", "usa");
     crm_xml_add(test_xml, "admin", "john");
     crm_xml_add(test_xml, "dummy", "value");
 
     // Set flags in test_xml's attributes for testing flag preservation
     attr = xmlHasProp(test_xml, (const xmlChar *) "location");
     if (attr != NULL) {
         xml_node_private_t *nodepriv = attr->_private;
 
         if (nodepriv != NULL) {
-            pcmk__set_xml_flags(nodepriv, pcmk__xf_lazy);
+            pcmk__set_xml_flags(nodepriv, pcmk__xf_ignore_attr_pos);
         }
     }
 
     attr = xmlHasProp(test_xml, (const xmlChar *) "admin");
     if (attr != NULL) {
         xml_node_private_t *nodepriv = attr->_private;
 
         if (nodepriv != NULL) {
             pcmk__clear_xml_flags(nodepriv, pcmk__xf_created|pcmk__xf_dirty);
         }
     }
 
     pcmk__xe_set_props(reference_xml,
                        "admin", "john",
                        "dummy", "value",
                        "location", "usa",
                        NULL);
 
     assert_order(test_xml, reference_xml);
 
     pcmk__xml_free(test_xml);
     pcmk__xml_free(reference_xml);
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(null_arg),
                 cmocka_unit_test(nothing_to_sort),
                 cmocka_unit_test(already_sorted),
                 cmocka_unit_test(need_sort))
diff --git a/lib/common/xml.c b/lib/common/xml.c
index 74bf14aa84..0c623b27ae 100644
--- a/lib/common/xml.c
+++ b/lib/common/xml.c
@@ -1,1660 +1,1904 @@
 /*
  * Copyright 2004-2025 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>              // xmlCleanupParser()
 #include <libxml/tree.h>                // xmlNode, etc.
 #include <libxml/xmlstring.h>           // xmlChar, 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 ((const xmlChar *) "1.0")
 
 /*!
  * \internal
  * \brief Get a string representation of an XML element type for logging
  *
  * \param[in] type  XML element type
  *
  * \return String representation of \p type
  */
 const char *
 pcmk__xml_element_type_text(xmlElementType type)
 {
     static const char *const element_type_names[] = {
         [XML_ELEMENT_NODE]       = "element",
         [XML_ATTRIBUTE_NODE]     = "attribute",
         [XML_TEXT_NODE]          = "text",
         [XML_CDATA_SECTION_NODE] = "CDATA section",
         [XML_ENTITY_REF_NODE]    = "entity reference",
         [XML_ENTITY_NODE]        = "entity",
         [XML_PI_NODE]            = "PI",
         [XML_COMMENT_NODE]       = "comment",
         [XML_DOCUMENT_NODE]      = "document",
         [XML_DOCUMENT_TYPE_NODE] = "document type",
         [XML_DOCUMENT_FRAG_NODE] = "document fragment",
         [XML_NOTATION_NODE]      = "notation",
         [XML_HTML_DOCUMENT_NODE] = "HTML document",
         [XML_DTD_NODE]           = "DTD",
         [XML_ELEMENT_DECL]       = "element declaration",
         [XML_ATTRIBUTE_DECL]     = "attribute declaration",
         [XML_ENTITY_DECL]        = "entity declaration",
         [XML_NAMESPACE_DECL]     = "namespace declaration",
         [XML_XINCLUDE_START]     = "XInclude start",
         [XML_XINCLUDE_END]       = "XInclude end",
     };
 
     // Assumes the numeric values of the indices are in ascending order
     if ((type < XML_ELEMENT_NODE) || (type > XML_XINCLUDE_END)) {
         return "unrecognized type";
     }
     return element_type_names[type];
 }
 
 /*!
  * \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 (xml == NULL) {
         return true;
     }
 
     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;
 }
 
 void
 pcmk__xml_set_parent_flags(xmlNode *xml, uint64_t flags)
 {
     for (; xml != NULL; xml = xml->parent) {
         xml_node_private_t *nodepriv = xml->_private;
 
         if (nodepriv != NULL) {
             pcmk__set_xml_flags(nodepriv, flags);
         }
     }
 }
 
 /*!
  * \internal
  * \brief Set flags for an XML document
  *
  * \param[in,out] doc    XML document
- * \param[in]     flags  Group of <tt>enum xml_private_flags</tt>
+ * \param[in]     flags  Group of <tt>enum pcmk__xml_flags</tt>
  */
 void
 pcmk__xml_doc_set_flags(xmlDoc *doc, uint32_t flags)
 {
     if (doc != NULL) {
         xml_doc_private_t *docpriv = doc->_private;
 
         pcmk__set_xml_flags(docpriv, flags);
     }
 }
 
 /*!
  * \internal
  * \brief Check whether the given flags are set for an XML document
  *
  * \param[in] doc    XML document to check
- * \param[in] flags  Group of <tt>enum xml_private_flags</tt>
+ * \param[in] flags  Group of <tt>enum pcmk__xml_flags</tt>
  *
  * \return \c true if all of \p flags are set for \p doc, or \c false otherwise
  */
 bool
 pcmk__xml_doc_all_flags_set(const xmlDoc *doc, uint32_t flags)
 {
     if (doc != NULL) {
         xml_doc_private_t *docpriv = doc->_private;
 
         return (docpriv != NULL) && pcmk_all_flags_set(docpriv->flags, flags);
     }
     return false;
 }
 
 // Mark document, element, and all element's parents as changed
 void
 pcmk__mark_xml_node_dirty(xmlNode *xml)
 {
     if (xml == NULL) {
         return;
     }
     pcmk__xml_doc_set_flags(xml->doc, 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().
  */
 bool
 pcmk__xml_reset_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
  */
 static void
 mark_xml_tree_dirty_created(xmlNode *xml)
 {
     pcmk__assert(xml != NULL);
 
     if (!pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking)) {
         // 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) {
         pcmk__assert(docpriv->check == PCMK__XML_DOC_PRIVATE_MAGIC);
 
         pcmk__str_update(&(docpriv->acl_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;
         }
     }
 }
 
 /*!
  * \internal
  * \brief Allocate and initialize private data for an XML node
  *
  * \param[in,out] node       XML node whose private data to initialize
  * \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
 new_private_data(xmlNode *node, void *user_data)
 {
+    bool tracking = false;
+
     CRM_CHECK(node != NULL, return true);
 
     if (node->_private != NULL) {
         return true;
     }
 
+    tracking = pcmk__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking);
+
     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;
                 node->_private = docpriv;
-                pcmk__set_xml_flags(docpriv, pcmk__xf_dirty|pcmk__xf_created);
             }
             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;
                 node->_private = nodepriv;
-                pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
+                if (tracking) {
+                    pcmk__set_xml_flags(nodepriv, pcmk__xf_dirty|pcmk__xf_created);
+                }
 
                 for (xmlAttr *iter = pcmk__xe_first_attr(node); iter != NULL;
                      iter = iter->next) {
 
                     new_private_data((xmlNode *) iter, user_data);
                 }
             }
             break;
 
         case XML_TEXT_NODE:
         case XML_DTD_NODE:
         case XML_CDATA_SECTION_NODE:
             return true;
 
         default:
             CRM_LOG_ASSERT(node->type == XML_ELEMENT_NODE);
             return true;
     }
 
-    if (pcmk__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking)) {
+    if (tracking) {
         pcmk__mark_xml_node_dirty(node);
     }
     return true;
 }
 
 /*!
  * \internal
  * \brief Free private data for an XML node
  *
  * \param[in,out] node       XML node whose private data to free
  * \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
 free_private_data(xmlNode *node, void *user_data)
 {
     CRM_CHECK(node != NULL, return true);
 
     if (node->_private == NULL) {
         return true;
     }
 
     if (node->type == XML_DOCUMENT_NODE) {
         reset_xml_private_data((xml_doc_private_t *) node->_private);
 
     } else {
         xml_node_private_t *nodepriv = node->_private;
 
         pcmk__assert(nodepriv->check == PCMK__XML_NODE_PRIVATE_MAGIC);
 
         for (xmlAttr *iter = pcmk__xe_first_attr(node); iter != NULL;
              iter = iter->next) {
 
             free_private_data((xmlNode *) iter, user_data);
         }
     }
     free(node->_private);
     node->_private = NULL;
     return true;
 }
 
 /*!
  * \internal
  * \brief Allocate and initialize private data recursively for an XML tree
  *
  * \param[in,out] node  XML node whose private data to initialize
  */
 void
 pcmk__xml_new_private_data(xmlNode *xml)
 {
     pcmk__xml_tree_foreach(xml, new_private_data, NULL);
 }
 
 /*!
  * \internal
  * \brief Free private data recursively for an XML tree
  *
  * \param[in,out] node  XML node whose private data to free
  */
 void
 pcmk__xml_free_private_data(xmlNode *xml)
 {
     pcmk__xml_tree_foreach(xml, free_private_data, NULL);
 }
 
-void
-xml_track_changes(xmlNode * xml, const char *user, xmlNode *acl_source, bool enforce_acls) 
-{
-    if (xml == NULL) {
-        return;
-    }
-
-    xml_accept_changes(xml);
-    crm_trace("Tracking changes%s to %p", enforce_acls?" with ACLs":"", xml);
-    pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_tracking);
-    if(enforce_acls) {
-        if(acl_source == NULL) {
-            acl_source = xml;
-        }
-        pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_acl_enabled);
-        pcmk__unpack_acl(acl_source, xml, user);
-        pcmk__apply_acl(xml);
-    }
-}
-
 /*!
  * \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)
+pcmk__xml_position(const xmlNode *xml, enum pcmk__xml_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)
+commit_attr_deletions(xmlNode *xml, void *user_data)
 {
     pcmk__xml_reset_node_flags(xml, NULL);
     pcmk__xe_remove_matching_attrs(xml, true, 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
+ * \brief Finalize all pending changes to an XML document and reset private data
+ *
+ * Clear the ACL user and all flags, unpacked ACLs, and deleted node records for
+ * the document; clear all flags on each node in the tree; and delete any
+ * attributes that are marked for deletion.
+ *
+ * \param[in,out] doc  XML document
+ *
+ * \note When change tracking is enabled, "deleting" an attribute simply marks
+ *       it for deletion (using \c pcmk__xf_deleted) until changes are
+ *       committed. Freeing a node (using \c pcmk__xml_free()) adds a deleted
+ *       node record (\c pcmk__deleted_xml_t) to the node's document before
+ *       freeing it.
+ * \note This function clears all flags, not just flags that indicate changes.
+ *       In particular, note that it clears the \c pcmk__xf_tracking flag, thus
+ *       disabling tracking.
  */
-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)
+pcmk__xml_commit_changes(xmlDoc *doc)
 {
-    xmlNode *top = NULL;
     xml_doc_private_t *docpriv = NULL;
 
-    if(xml == NULL) {
+    if (doc == NULL) {
         return;
     }
 
-    crm_trace("Accepting changes to %p", xml);
-    docpriv = xml->doc->_private;
-    top = xmlDocGetRootElement(xml->doc);
+    docpriv = doc->_private;
+    if (docpriv == NULL) {
+        return;
+    }
 
     if (pcmk_is_set(docpriv->flags, pcmk__xf_dirty)) {
-        pcmk__xml_tree_foreach(top, accept_attr_deletions, NULL);
+        pcmk__xml_tree_foreach(xmlDocGetRootElement(doc), commit_attr_deletions,
+                               NULL);
     }
-    reset_xml_private_data(xml->doc->_private);
+    reset_xml_private_data(docpriv);
     docpriv->flags = pcmk__xf_none;
 }
 
 /*!
  * \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)
 {
     xmlDoc *doc = xmlNewDoc(XML_VERSION);
 
     pcmk__mem_assert(doc);
     pcmk__xml_new_private_data((xmlNode *) 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)
 {
     if (doc != NULL) {
         pcmk__xml_free_private_data((xmlNode *) doc);
         xmlFreeDoc(doc);
     }
 }
 
 /*!
  * \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((const xmlChar *) 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((const xmlChar *) 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
     pcmk__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 Free an XML tree without ACL checks or change tracking
  *
  * \param[in,out] xml  XML node to free
  */
 void
 pcmk__xml_free_node(xmlNode *xml)
 {
     pcmk__xml_free_private_data(xml);
     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)
+ *
+ * \return Standard Pacemaker return code
  */
-static void
+static int
 free_xml_with_position(xmlNode *node, int position)
 {
     xmlDoc *doc = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     if (node == NULL) {
-        return;
+        return pcmk_rc_ok;
     }
     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;
+        return pcmk_rc_ok;
     }
 
     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;
+        pcmk__if_tracing(
+            {
+                GString *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 EACCES;
     }
 
     if (pcmk__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking)
         && !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__xml_doc_set_flags(node->doc, pcmk__xf_dirty);
         }
     }
     pcmk__xml_free_node(node);
+    return pcmk_rc_ok;
 }
 
 /*!
  * \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
         pcmk__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_new_private_data(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:
                 pcmk__xml_free_node(iter);
                 break;
 
             case XML_ELEMENT_NODE:
                 /* Search it */
                 pcmk__strip_xml_text(iter);
                 break;
 
             default:
                 /* Leave it */
                 break;
         }
 
         iter = next;
     }
 }
 
 /*!
  * \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
                 pcmk__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
                 pcmk__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, (const xmlChar *) attr_name);
     nodepriv = attr->_private;
     nodepriv->flags = 0;
 
     // Check ACLs and mark restored value for later removal
     pcmk__xa_remove(attr, false);
 
     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__xml_doc_all_flags_set(new_xml->doc,
-                                                       pcmk__xf_lazy
+                                                       pcmk__xf_ignore_attr_pos
                                                        |pcmk__xf_tracking)) {
-                /* pcmk__xf_tracking is always set by xml_calculate_changes()
-                 * before this function is called, so only the pcmk__xf_lazy
-                 * check is truly relevant.
+                /* pcmk__xf_tracking is always set by pcmk__xml_mark_changes()
+                 * before this function is called, so only the
+                 * pcmk__xf_ignore_attr_pos check is truly relevant.
                  */
                 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
                 pcmk__xa_remove(new_attr, true);
             }
         }
     }
 }
 
 /*!
  * \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
+    // Cleared later if attributes are not really new
+    for (xmlAttr *attr = pcmk__xe_first_attr(new_xml); attr != NULL;
+         attr = attr->next) {
+        xml_node_private_t *nodepriv = attr->_private;
+
+        pcmk__set_xml_flags(nodepriv, pcmk__xf_created);
+    }
+
     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
+ * \brief Add a deleted object record for an old XML child if ACLs allow
  *
- * 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.
+ * This is intended to be called for a child of an old XML element that is not
+ * present as a child of a new XML element.
  *
- * \param[in,out] old_child    Child element from original XML
- * \param[in,out] new_parent   New XML to add marked copy to
+ * Add a temporary copy of the old child to the new XML. Then check whether ACLs
+ * would have allowed the deletion of that element. If so, add a deleted object
+ * record for it to the new XML's document, and set the \c pcmk__xf_skip flag on
+ * the old child.
+ *
+ * The temporary copy is removed before returning. The new XML and all of its
+ * ancestors will have the \c pcmk__xf_dirty flag set because of the creation,
+ * however.
+ *
+ * \param[in,out] old_child   Child of old XML
+ * \param[in,out] new_parent  New XML that does not contain \p old_child
  */
 static void
 mark_child_deleted(xmlNode *old_child, xmlNode *new_parent)
 {
+    int pos = pcmk__xml_position(old_child, pcmk__xf_skip);
+
     // 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, pcmk__xml_reset_node_flags, NULL);
 
-    // Check whether ACLs allow the deletion
+    // free_xml_with_position() will 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);
+    /* Try to remove the child again (which will track it in document's
+     * deleted_objs on success)
+     */
+    if (free_xml_with_position(candidate, pos) != pcmk_rc_ok) {
+        // ACLs denied deletion in free_xml_with_position. Free candidate here.
+        pcmk__xml_free_node(candidate);
     }
+
+    pcmk__set_xml_flags((xml_node_private_t *) old_child->_private,
+                        pcmk__xf_skip);
 }
 
+/*!
+ * \internal
+ * \brief Mark a new child as moved and set \c pcmk__xf_skip as appropriate
+ *
+ * \param[in,out] old_child  Child of old XML
+ * \param[in,out] new_child  Child of new XML that matches \p old_child
+ * \param[in]     old_pos    Position of \p old_child among its siblings
+ * \param[in]     new_pos    Position of \p new_child among its siblings
+ */
 static void
-mark_child_moved(xmlNode *old_child, xmlNode *new_parent, xmlNode *new_child,
-                 int p_old, int p_new)
+mark_child_moved(xmlNode *old_child, xmlNode *new_child, int old_pos,
+                 int new_pos)
 {
+    const char *id_s = pcmk__s(pcmk__xe_id(new_child), "<no id>");
+    xmlNode *new_parent = new_child->parent;
     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);
+    crm_trace("Child element %s with " PCMK_XA_ID "='%s' moved from position "
+              "%d to %d under %s",
+              new_child->name, id_s, old_pos, new_pos, new_parent->name);
     pcmk__mark_xml_node_dirty(new_parent);
     pcmk__set_xml_flags(nodepriv, pcmk__xf_moved);
 
-    if (p_old > p_new) {
+    /* @TODO Figure out and document why we skip the old child in future
+     * position calculations if the old position is higher, and skip the new
+     * child in future position calculations if the new position is higher. This
+     * goes back to d028b52, and there's no explanation in the commit message.
+     */
+    if (old_pos > new_pos) {
         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)
+/*!
+ * \internal
+ * \brief Check whether a new XML child comment matches an old XML child comment
+ *
+ * Two comments match if they have the same position among their siblings and
+ * the same contents.
+ *
+ * If \p new_comment has the \c pcmk__xf_skip flag set, then it is automatically
+ * considered not to match.
+ *
+ * \param[in] old_comment  Old XML child element
+ * \param[in] new_comment  New XML child element
+ *
+ * \retval \c true   if \p new_comment matches \p old_comment
+ * \retval \c false  otherwise
+ */
+static bool
+new_comment_matches(const xmlNode *old_comment, const xmlNode *new_comment)
 {
-    xmlNode *old_child = NULL;
-    xmlNode *new_child = NULL;
-    xml_node_private_t *nodepriv = NULL;
+    xml_node_private_t *nodepriv = new_comment->_private;
 
-    CRM_CHECK(new_xml != NULL, return);
-    if (old_xml == NULL) {
-        mark_xml_tree_dirty_created(new_xml);
-        pcmk__apply_creation_acl(new_xml, check_top);
-        return;
+    if (pcmk_is_set(nodepriv->flags, pcmk__xf_skip)) {
+        /* @TODO Should we also return false if old_comment has pcmk__xf_skip
+         * set? This preserves existing behavior at time of writing.
+         */
+        return false;
     }
+    if (pcmk__xml_position(old_comment, pcmk__xf_skip)
+        != pcmk__xml_position(new_comment, pcmk__xf_skip)) {
+        return false;
+    }
+    return pcmk__xc_matches(old_comment, new_comment);
+}
 
-    nodepriv = new_xml->_private;
-    CRM_CHECK(nodepriv != NULL, return);
+/*!
+ * \internal
+ * \brief Check whether a new XML child element matches an old XML child element
+ *
+ * Two elements match if they have the same name and, if \p match_ids is
+ * \c true, the same ID. (Both IDs can be \c NULL in this case.)
+ *
+ * \param[in] old_element  Old XML child element
+ * \param[in] new_element  New XML child element
+ * \param[in] match_ids    If \c true, require IDs to match (or both to be
+ *                         \c NULL)
+ *
+ * \retval \c true   if \p new_element matches \p old_element
+ * \retval \c false  otherwise
+ */
+static bool
+new_element_matches(const xmlNode *old_element, const xmlNode *new_element,
+                    bool match_ids)
+{
+    if (!pcmk__xe_is(new_element, (const char *) old_element->name)) {
+        return false;
+    }
+    return !match_ids
+           || pcmk__str_eq(pcmk__xe_id(old_element), pcmk__xe_id(new_element),
+                           pcmk__str_none);
+}
 
-    if(nodepriv->flags & pcmk__xf_processed) {
-        /* Avoid re-comparing nodes */
-        return;
+/*!
+ * \internal
+ * \brief Check whether a new XML child node matches an old XML child node
+ *
+ * Node types must be the same in order to match.
+ *
+ * For comments, a match is a comment at the same position with the same
+ * content.
+ *
+ * For elements, a match is an element with the same name and, if required, the
+ * same ID. (Both IDs can be \c NULL in this case.)
+ *
+ * For other node types, there is no match.
+ *
+ * \param[in] old_child  Child of old XML
+ * \param[in] new_child  Child of new XML
+ * \param[in] match_ids  If \c true, require element IDs to match (or both to be
+ *                       \c NULL)
+ *
+ * \retval \c true   if \p new_child matches \p old_child
+ * \retval \c false  otherwise
+ */
+static bool
+new_child_matches(const xmlNode *old_child, const xmlNode *new_child,
+                  bool match_ids)
+{
+    if (old_child->type != new_child->type) {
+        return false;
     }
-    pcmk__set_xml_flags(nodepriv, pcmk__xf_processed);
 
-    xml_diff_attrs(old_xml, new_xml);
+    switch (old_child->type) {
+        case XML_COMMENT_NODE:
+            return new_comment_matches(old_child, new_child);
+        case XML_ELEMENT_NODE:
+            return new_element_matches(old_child, new_child, match_ids);
+        default:
+            return false;
+    }
+}
 
-    // Check for differences in the original children
-    for (old_child = pcmk__xml_first_child(old_xml); old_child != NULL;
+/*!
+ * \internal
+ * \brief Find matching XML node pairs between old and new XML's children
+ *
+ * A node that is part of a matching pair has its <tt>_private:match</tt> member
+ * set to the matching node.
+ *
+ * \param[in,out] old_xml       Old XML
+ * \param[in,out] new_xml       New XML
+ * \param[in]     comments_ids  If \c true, match comments and require element
+ *                              IDs to match; otherwise, skip comments and match
+ *                              elements by name only
+ */
+static void
+find_matching_children(xmlNode *old_xml, xmlNode *new_xml, bool comments_ids)
+{
+    for (xmlNode *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);
+        xml_node_private_t *old_nodepriv = old_child->_private;
 
-        } else {
-            mark_child_deleted(old_child, new_xml);
+        if ((old_nodepriv == NULL) || (old_nodepriv->match != NULL)) {
+            // Can't process, or we already found a match for this old child
+            continue;
+        }
+        if (!comments_ids && (old_child->type != XML_ELEMENT_NODE)) {
+            /* We only match comments and elements, and we're not matching
+             * comments during this call
+             */
+            continue;
         }
-    }
-
-    // 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);
+        for (xmlNode *new_child = pcmk__xml_first_child(new_xml);
+             new_child != NULL; new_child = pcmk__xml_next(new_child)) {
 
-            // May free new_child
-            mark_xml_changes(old_child, new_child, true);
+            xml_node_private_t *new_nodepriv = new_child->_private;
 
-        } 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 ((new_nodepriv == NULL) || (new_nodepriv->match != NULL)) {
+                /* Can't process, or this new child already matched some old
+                 * child
+                 */
+                continue;
+            }
 
-            if(p_old != p_new) {
-                mark_child_moved(old_child, new_xml, new_child, p_old, p_new);
+            if (new_child_matches(old_child, new_child, comments_ids)) {
+                old_nodepriv->match = new_child;
+                new_nodepriv->match = old_child;
+                break;
             }
         }
-
-        new_child = next;
     }
 }
 
+/*!
+ * \internal
+ * \brief Mark changes between two XML trees
+ *
+ * Set flags in a new XML tree to indicate changes relative to an old XML tree.
+ *
+ * \param[in,out] old_xml  XML before changes
+ * \param[in,out] new_xml  XML after changes
+ *
+ * \note This may set \c pcmk__xf_skip on parts of \p old_xml.
+ */
 void
-xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml)
+pcmk__xml_mark_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
-    if (new_xml != NULL) {
-        pcmk__xml_doc_set_flags(new_xml->doc, pcmk__xf_lazy);
+    /* This function may set the xml_node_private_t:match member on children of
+     * old_xml and new_xml, but it clears that member before returning.
+     *
+     * @TODO Ensure we handle (for example, by copying) or reject user-created
+     * XML that is missing xml_node_private_t at top level or in any children.
+     * Similarly, check handling of node types for which we don't create private
+     * data. For now, we'll skip them in the loops below.
+     */
+    CRM_CHECK((old_xml != NULL) && (new_xml != NULL), return);
+    if ((old_xml->_private == NULL) || (new_xml->_private == NULL)) {
+        return;
     }
-    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);
+    pcmk__xml_doc_set_flags(new_xml->doc, pcmk__xf_tracking);
+    xml_diff_attrs(old_xml, new_xml);
 
-    if (!pcmk__xml_doc_all_flags_set(new_xml->doc, pcmk__xf_tracking)) {
-        xml_track_changes(new_xml, NULL, NULL, FALSE);
+    find_matching_children(old_xml, new_xml, true);
+    find_matching_children(old_xml, new_xml, false);
+
+    // Process matches (changed children) and deletions
+    for (xmlNode *old_child = pcmk__xml_first_child(old_xml); old_child != NULL;
+         old_child = pcmk__xml_next(old_child)) {
+
+        xml_node_private_t *nodepriv = old_child->_private;
+        xmlNode *new_child = NULL;
+
+        if (nodepriv == NULL) {
+            continue;
+        }
+
+        if (nodepriv->match == NULL) {
+            // No match in new XML means the old child was deleted
+            mark_child_deleted(old_child, new_xml);
+            continue;
+        }
+
+        /* Fetch the match and clear old_child->_private's match member.
+         * new_child->_private's match member is handled in the new_xml loop.
+         */
+        new_child = nodepriv->match;
+        nodepriv->match = NULL;
+
+        pcmk__assert(old_child->type == new_child->type);
+
+        if (old_child->type == XML_COMMENT_NODE) {
+            // Comments match only if their positions and contents match
+            continue;
+        }
+
+        pcmk__xml_mark_changes(old_child, new_child);
     }
 
-    mark_xml_changes(old_xml, new_xml, FALSE);
+    /* Mark unmatched new children as created, and mark matched new children as
+     * moved if their positions changed. Grab the next new child in advance,
+     * since new_child may get freed in the loop body.
+     */
+    for (xmlNode *new_child = pcmk__xml_first_child(new_xml),
+                 *next = pcmk__xml_next(new_child);
+         new_child != NULL;
+         new_child = next, next = pcmk__xml_next(new_child)) {
+
+        xml_node_private_t *nodepriv = new_child->_private;
+
+        if (nodepriv == NULL) {
+            continue;
+        }
+
+        if (nodepriv->match != NULL) {
+            /* Fetch the match and clear new_child->_private's match member. Any
+             * changes were marked in the old_xml loop. Mark the move.
+             *
+             * We might be able to mark the move earlier, when we mark changes
+             * for matches in the old_xml loop, consolidating both actions. We'd
+             * have to think about whether the timing of setting the
+             * pcmk__xf_skip flag makes any difference.
+             */
+            xmlNode *old_child = nodepriv->match;
+            int old_pos = pcmk__xml_position(old_child, pcmk__xf_skip);
+            int new_pos = pcmk__xml_position(new_child, pcmk__xf_skip);
+
+            if (old_pos != new_pos) {
+                mark_child_moved(old_child, new_child, old_pos, new_pos);
+            }
+            nodepriv->match = NULL;
+            continue;
+        }
+
+        // No match in old XML means the new child is newly created
+        pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
+        mark_xml_tree_dirty_created(new_child);
+
+        // Check whether creation was allowed (may free new_child)
+        pcmk__apply_creation_acl(new_child, true);
+    }
 }
 
 /*!
  * \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);
 
         // 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;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #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);
     pcmk__xml_new_private_data(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)
 {
     pcmk__xml_free_node(xml);
 }
 
 void
 free_xml(xmlNode *child)
 {
     pcmk__xml_free(child);
 }
 
 void
 crm_xml_sanitize_id(char *id)
 {
     char *c;
 
     for (c = id; *c; ++c) {
         switch (*c) {
             case ':':
             case '#':
                 *c = '.';
         }
     }
 }
 
 bool
 xml_tracking_changes(xmlNode *xml)
 {
     return (xml != NULL)
            && pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_tracking);
 }
 
 bool
 xml_document_dirty(xmlNode *xml)
 {
     return (xml != NULL)
            && pcmk__xml_doc_all_flags_set(xml->doc, pcmk__xf_dirty);
 }
 
+void
+xml_accept_changes(xmlNode *xml)
+{
+    if (xml != NULL) {
+        pcmk__xml_commit_changes(xml->doc);
+    }
+}
+
+void
+xml_track_changes(xmlNode *xml, const char *user, xmlNode *acl_source,
+                  bool enforce_acls)
+{
+    if (xml == NULL) {
+        return;
+    }
+
+    pcmk__xml_commit_changes(xml->doc);
+    crm_trace("Tracking changes%s to %p",
+              (enforce_acls? " with ACLs" : ""), xml);
+    pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_tracking);
+    if (enforce_acls) {
+        if (acl_source == NULL) {
+            acl_source = xml;
+        }
+        pcmk__xml_doc_set_flags(xml->doc, pcmk__xf_acl_enabled);
+        pcmk__unpack_acl(acl_source, xml, user);
+        pcmk__apply_acl(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 (!pcmk__xml_doc_all_flags_set(new_xml->doc, pcmk__xf_tracking)) {
+        // Ensure tracking has a clean start (pcmk__xml_mark_changes() enables)
+        pcmk__xml_commit_changes(new_xml->doc);
+    }
+
+    pcmk__xml_mark_changes(old_xml, new_xml);
+}
+
+void
+xml_calculate_significant_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);
+
+    /* BUG: If pcmk__xf_tracking is not set for new_xml when this function is
+     * called, then we unset pcmk__xf_ignore_attr_pos via
+     * pcmk__xml_commit_changes(). Since this function is about to be
+     * deprecated, it's not worth fixing this and changing the user-facing
+     * behavior.
+     */
+    pcmk__xml_doc_set_flags(new_xml->doc, pcmk__xf_ignore_attr_pos);
+
+    if (!pcmk__xml_doc_all_flags_set(new_xml->doc, pcmk__xf_tracking)) {
+        // Ensure tracking has a clean start (pcmk__xml_mark_changes() enables)
+        pcmk__xml_commit_changes(new_xml->doc);
+    }
+
+    pcmk__xml_mark_changes(old_xml, new_xml);
+}
+
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/lib/common/xml_comment.c b/lib/common/xml_comment.c
index 2bc2848ef6..ae4443ca9f 100644
--- a/lib/common/xml_comment.c
+++ b/lib/common/xml_comment.c
@@ -1,116 +1,120 @@
 /*
  * Copyright 2024-2025 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 <stdbool.h>                    // bool, false
 #include <stdio.h>                      // NULL
 
 #include <libxml/tree.h>                // xmlDoc, xmlNode, etc.
 #include <libxml/xmlstring.h>           // xmlChar
 
 #include "crmcommon_private.h"
 
 /*!
  * \internal
  * \brief Create a new XML comment belonging to a given document
  *
  * \param[in] doc      Document that new comment will belong to
  * \param[in] content  Comment content
  *
  * \return Newly created XML comment (guaranteed not to be \c NULL)
  */
 xmlNode *
 pcmk__xc_create(xmlDoc *doc, const char *content)
 {
     xmlNode *node = NULL;
 
     // Pacemaker typically assumes every xmlNode has a doc
     pcmk__assert(doc != NULL);
 
     node = xmlNewDocComment(doc, (const xmlChar *) content);
     pcmk__mem_assert(node);
     pcmk__xml_new_private_data(node);
     return node;
 }
 
 /*!
  * \internal
- * \brief Find a comment with matching content in specified XML
+ * \brief Check whether two comments have matching content (case-insensitive)
  *
- * \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
+ * \param[in] comment1  First comment node to compare
+ * \param[in] comment2  Second comment node to compare
+ *
+ * \return \c true if \p comment1 and \p comment2 have matching content (by
+ *         case-insensitive string comparison), or \c false otherwise
  */
-xmlNode *
-pcmk__xc_match(const xmlNode *root, const xmlNode *search_comment, bool exact)
+bool
+pcmk__xc_matches(const xmlNode *comment1, const xmlNode *comment2)
 {
-    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);
+    pcmk__assert((comment1 != NULL) && (comment1->type == XML_COMMENT_NODE)
+                 && (comment2 != NULL) && (comment2->type == XML_COMMENT_NODE));
 
-    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;
+    return pcmk__str_eq((const char *) comment1->content,
+                        (const char *) comment2->content, pcmk__str_casei);
+}
 
-            if (offset < search_offset) {
-                continue;
+/*!
+ * \internal
+ * \brief Find a comment with matching content among children of specified XML
+ *
+ * \param[in] parent  XML whose children to search
+ * \param[in] search  Comment whose content should be searched for
+ *
+ * \return Matching comment, or \c NULL if no match is found
+ */
+static xmlNode *
+match_xc_child(const xmlNode *parent, const xmlNode *search)
+{
+    pcmk__assert((search != NULL) && (search->type == XML_COMMENT_NODE));
 
-            } else if (offset > search_offset) {
-                return NULL;
-            }
+    for (xmlNode *child = pcmk__xml_first_child(parent); child != NULL;
+         child = pcmk__xml_next(child)) {
 
-            if (pcmk_is_set(nodepriv->flags, pcmk__xf_skip)) {
-                continue;
-            }
+        if (child->type != XML_COMMENT_NODE) {
+            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;
+        if (pcmk__xc_matches(child, search)) {
+            return child;
         }
     }
 
     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);
+        target = match_xc_child(parent, update);
     }
 
     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);
     }
 }
diff --git a/lib/common/xml_element.c b/lib/common/xml_element.c
index 2361f8ff64..426ef3c898 100644
--- a/lib/common/xml_element.c
+++ b/lib/common/xml_element.c
@@ -1,1609 +1,1609 @@
 /*
  * Copyright 2004-2025 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>                     // va_start(), etc.
 #include <stdint.h>                     // uint32_t
 #include <stdio.h>                      // NULL, etc.
 #include <stdlib.h>                     // free(), etc.
 #include <string.h>                     // strchr(), etc.
 #include <sys/types.h>                  // time_t, etc.
 
 #include <libxml/tree.h>                // xmlNode, etc.
 #include <libxml/valid.h>               // xmlValidateNameValue()
 #include <libxml/xmlstring.h>           // xmlChar
 
 #include <crm/crm.h>
 #include <crm/common/nvpair.h>          // crm_xml_add(), etc.
 #include <crm/common/results.h>         // pcmk_rc_ok, etc.
 #include <crm/common/xml.h>
 #include "crmcommon_private.h"
 
 /*!
  * \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;
 
     CRM_CHECK((attr_v == NULL) || (attr_n != NULL), return NULL);
 
     if (parent == NULL) {
         return NULL;
     }
 
     child = parent->children;
     while ((child != NULL) && (child->type != XML_ELEMENT_NODE)) {
         child = child->next;
     }
 
     for (; child != NULL; child = pcmk__xe_next(child, NULL)) {
         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 (attr_n == NULL) {
         crm_trace("%s XML has no child element of %s type",
                   (const char *) parent->name, pcmk__s(node_name, "any"));
     } else {
         crm_trace("%s XML has no child element of %s type with %s='%s'",
                   (const char *) parent->name, pcmk__s(node_name, "any"),
                   attr_n, attr_v);
     }
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Return next sibling element of an XML element
  *
  * \param[in] xml           XML element to check
  * \param[in] element_name  If not NULL, get next sibling with this element name
  *
  * \return Next desired sibling of \p xml (or NULL if none)
  */
 xmlNode *
 pcmk__xe_next(const xmlNode *xml, const char *element_name)
 {
     for (xmlNode *next = (xml == NULL)? NULL : xml->next;
          next != NULL; next = next->next) {
         if ((next->type == XML_ELEMENT_NODE)
             && ((element_name == NULL) || pcmk__xe_is(next, element_name))) {
             return next;
         }
     }
     return NULL;
 }
 
 /*!
  * \internal
  * \brief Parse an integer score from an XML attribute
  *
  * \param[in]  xml            XML element with attribute to parse
  * \param[in]  name           Name of attribute to parse
  * \param[out] score          Where to store parsed score (can be NULL to
  *                            just validate)
  * \param[in]  default_score  What to return if the attribute value is not
  *                            present or invalid
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__xe_get_score(const xmlNode *xml, const char *name, int *score,
                    int default_score)
 {
     const char *value = NULL;
 
     CRM_CHECK((xml != NULL) && (name != NULL), return EINVAL);
     value = crm_element_value(xml, name);
     return pcmk_parse_score(value, score, default_score);
 }
 
 /*!
  * \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 pcmk_parse_score().
  *
  * 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 (if NULL, initial value
  *                        will be left unchanged)
  *
  * \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) {
         // @TODO Maybe instead delete the attribute or set it to 0
         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 == '='))) {
 
             int add = 1;
             int old_value_i = 0;
             int rc = pcmk_rc_ok;
 
             // If we're expanding ourselves, no previous value was set; use 0
             if (old_value != value) {
                 rc = pcmk_parse_score(old_value, &old_value_i, 0);
                 if (rc != pcmk_rc_ok) {
                     // @TODO This is inconsistent with old_value==NULL
                     crm_trace("Using 0 before incrementing %s because '%s' "
                               "is not a score", name, old_value);
                 }
             }
 
             /* 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)
              */
             if (*v != '+') {
                 rc = pcmk_parse_score(++v, &add, 0);
                 if (rc != pcmk_rc_ok) {
                     // @TODO We should probably skip expansion instead
                     crm_trace("Not incrementing %s because '%s' does not have "
                               "a valid increment", name, value);
                 }
             }
 
             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 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) {
         pcmk__xa_remove(xmlHasProp(element, (const xmlChar *) name), false);
     }
 }
 
 /*!
  * \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]     force      If \c true, remove matching attributes immediately,
  *                           ignoring ACLs and change tracking
  * \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 force,
                                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 (pcmk__xa_remove(a, force) != 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;
 
     pcmk__assert(!pcmk__str_empty(name));
 
     if (parent == NULL) {
         xmlDoc *doc = pcmk__xml_new_doc();
 
         node = xmlNewDocRawNode(doc, NULL, (const xmlChar *) name, NULL);
         pcmk__mem_assert(node);
 
         xmlDocSetRootElement(doc, node);
 
     } else {
         node = xmlNewChild(parent, NULL, (const xmlChar *) name, NULL);
         pcmk__mem_assert(node);
     }
 
     pcmk__xml_new_private_data(node);
     return node;
 }
 
 /*!
  * \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;
 
         /* xmlNodeSetContent() frees node->children and replaces it with new
          * text. If this function is called for a node that already has a non-
          * text child, it's a bug.
          */
         CRM_CHECK((node->children == NULL)
                   || (node->children->type == XML_TEXT_NODE),
                   return);
 
         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 {
                 pcmk__assert(vasprintf(&buf, format, ap) >= 0);
                 content = buf;
             }
             va_end(ap);
         }
 
         xmlNodeSetContent(node, (const xmlChar *) content);
         free(buf);
     }
 }
 
 /*!
  * \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;
 
     pcmk__assert(!pcmk__str_empty(format));
 
     if (node == NULL) {
         return;
     }
 
     va_start(ap, format);
     pcmk__assert(vasprintf(&id, format, ap) >= 0);
     va_end(ap);
 
     if (!xmlValidateNameValue((const xmlChar *) id)) {
         pcmk__xml_sanitize_id(id);
     }
     crm_xml_add(node, PCMK_XA_ID, id);
     free(id);
 }
 
 /*!
  * \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 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, NULL)) {
 
         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)
 {
     // Pass old for its doc; it won't remain the parent of new
     new = pcmk__xml_copy(old, new);
     old = xmlReplaceNode(old, new);
 
     // old == NULL means memory allocation error
     pcmk__assert(old != NULL);
 
     // May be unnecessary but avoids slight changes to some test outputs
     pcmk__xml_tree_foreach(new, pcmk__xml_reset_node_flags, NULL);
 
     if (pcmk__xml_doc_all_flags_set(new->doc, pcmk__xf_tracking)) {
         // Replaced sections may have included relevant ACLs
         pcmk__apply_acl(new);
     }
-    xml_calculate_changes(old, new);
+    pcmk__xml_mark_changes(old, new);
     pcmk__xml_free_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, NULL)) {
 
         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;
 }
 
 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);
 
     pcmk__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;
 }
 
 // XML attribute handling
 
 /*!
  * \brief Create an XML attribute with specified name and value
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value on success, \c NULL otherwise
  * \note This does nothing if node, name, or value are \c NULL or empty.
  */
 const char *
 crm_xml_add(xmlNode *node, const char *name, const char *value)
 {
     // @TODO Replace with internal function that returns the new attribute
     bool dirty = FALSE;
     xmlAttr *attr = NULL;
 
     CRM_CHECK(node != NULL, return NULL);
     CRM_CHECK(name != NULL, return NULL);
 
     if (value == NULL) {
         return NULL;
     }
 
     if (pcmk__xml_doc_all_flags_set(node->doc, pcmk__xf_tracking)) {
         const char *old = crm_element_value(node, name);
 
         if (old == NULL || value == NULL || strcmp(old, value) != 0) {
             dirty = TRUE;
         }
     }
 
     if (dirty && (pcmk__check_acl(node, name, pcmk__xf_acl_create) == FALSE)) {
         crm_trace("Cannot add %s=%s to %s", name, value, node->name);
         return NULL;
     }
 
     attr = xmlSetProp(node, (const xmlChar *) name, (const xmlChar *) value);
 
     /* If the attribute already exists, this does nothing. Attribute values
      * don't get private data.
      */
     pcmk__xml_new_private_data((xmlNode *) attr);
 
     if (dirty) {
         pcmk__mark_xml_attr_dirty(attr);
     }
 
     CRM_CHECK(attr && attr->children && attr->children->content, return NULL);
     return (char *)attr->children->content;
 }
 
 
 /*!
  * \brief Create an XML attribute with specified name and integer value
  *
  * This is like \c crm_xml_add() but taking an integer value.
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if node or name are \c NULL or empty.
  */
 const char *
 crm_xml_add_int(xmlNode *node, const char *name, int value)
 {
     char *number = pcmk__itoa(value);
     const char *added = crm_xml_add(node, name, number);
 
     free(number);
     return added;
 }
 
 /*!
  * \brief Create an XML attribute with specified name and unsigned value
  *
  * This is like \c crm_xml_add() but taking a guint value.
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     ms     Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if node or name are \c NULL or empty.
  */
 const char *
 crm_xml_add_ms(xmlNode *node, const char *name, guint ms)
 {
     char *number = crm_strdup_printf("%u", ms);
     const char *added = crm_xml_add(node, name, number);
 
     free(number);
     return added;
 }
 
 // Maximum size of null-terminated string representation of 64-bit integer
 // -9223372036854775808
 #define LLSTRSIZE 21
 
 /*!
  * \brief Create an XML attribute with specified name and long long int value
  *
  * This is like \c crm_xml_add() but taking a long long int value. It is a
  * useful equivalent for defined types like time_t, etc.
  *
  * \param[in,out] xml    XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if xml or name are \c NULL or empty.
  *       This does not support greater than 64-bit values.
  */
 const char *
 crm_xml_add_ll(xmlNode *xml, const char *name, long long value)
 {
     char s[LLSTRSIZE] = { '\0', };
 
     if (snprintf(s, LLSTRSIZE, "%lld", (long long) value) == LLSTRSIZE) {
         return NULL;
     }
     return crm_xml_add(xml, name, s);
 }
 
 /*!
  * \brief Create XML attributes for seconds and microseconds
  *
  * This is like \c crm_xml_add() but taking a struct timeval.
  *
  * \param[in,out] xml        XML node to modify
  * \param[in]     name_sec   Name of XML attribute for seconds
  * \param[in]     name_usec  Name of XML attribute for microseconds (or NULL)
  * \param[in]     value      Time value to set
  *
  * \return New seconds value as string on success, \c NULL otherwise
  * \note This does nothing if xml, name_sec, or value is \c NULL.
  */
 const char *
 crm_xml_add_timeval(xmlNode *xml, const char *name_sec, const char *name_usec,
                     const struct timeval *value)
 {
     const char *added = NULL;
 
     if (xml && name_sec && value) {
         added = crm_xml_add_ll(xml, name_sec, (long long) value->tv_sec);
         if (added && name_usec) {
             // Any error is ignored (we successfully added seconds)
             crm_xml_add_ll(xml, name_usec, (long long) value->tv_usec);
         }
     }
     return added;
 }
 
 /*!
  * \brief Retrieve the value of an XML attribute
  *
  * \param[in] data   XML node to check
  * \param[in] name   Attribute name to check
  *
  * \return Value of specified attribute (may be \c NULL)
  */
 const char *
 crm_element_value(const xmlNode *data, const char *name)
 {
     xmlAttr *attr = NULL;
 
     if (data == NULL) {
         crm_err("Couldn't find %s in NULL", name ? name : "<null>");
         CRM_LOG_ASSERT(data != NULL);
         return NULL;
 
     } else if (name == NULL) {
         crm_err("Couldn't find NULL in %s", data->name);
         return NULL;
     }
 
     attr = xmlHasProp(data, (const xmlChar *) name);
     if (!attr || !attr->children) {
         return NULL;
     }
     return (const char *) attr->children->content;
 }
 
 /*!
  * \brief Retrieve the integer value of an XML attribute
  *
  * This is like \c crm_element_value() but getting the value as an integer.
  *
  * \param[in]  data  XML node to check
  * \param[in]  name  Attribute name to check
  * \param[out] dest  Where to store element value
  *
  * \return 0 on success, -1 otherwise
  */
 int
 crm_element_value_int(const xmlNode *data, const char *name, int *dest)
 {
     const char *value = NULL;
 
     CRM_CHECK(dest != NULL, return -1);
     value = crm_element_value(data, name);
     if (value) {
         long long value_ll;
         int rc = pcmk__scan_ll(value, &value_ll, 0LL);
 
         *dest = PCMK__PARSE_INT_DEFAULT;
         if (rc != pcmk_rc_ok) {
             crm_warn("Using default for %s "
                      "because '%s' is not a valid integer: %s",
                      name, value, pcmk_rc_str(rc));
         } else if ((value_ll < INT_MIN) || (value_ll > INT_MAX)) {
             crm_warn("Using default for %s because '%s' is out of range",
                      name, value);
         } else {
             *dest = (int) value_ll;
             return 0;
         }
     }
     return -1;
 }
 
 /*!
  * \internal
  * \brief Retrieve a flag group from an XML attribute value
  *
  * This is like \c crm_element_value() except getting the value as a 32-bit
  * unsigned integer.
  *
  * \param[in]  xml            XML node to check
  * \param[in]  name           Attribute name to check (must not be NULL)
  * \param[out] dest           Where to store flags (may be NULL to just
  *                            validate type)
  * \param[in]  default_value  What to use for missing or invalid value
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__xe_get_flags(const xmlNode *xml, const char *name, uint32_t *dest,
                    uint32_t default_value)
 {
     const char *value = NULL;
     long long value_ll = 0LL;
     int rc = pcmk_rc_ok;
 
     if (dest != NULL) {
         *dest = default_value;
     }
 
     if (name == NULL) {
         return EINVAL;
     }
     if (xml == NULL) {
         return pcmk_rc_ok;
     }
     value = crm_element_value(xml, name);
     if (value == NULL) {
         return pcmk_rc_ok;
     }
 
     rc = pcmk__scan_ll(value, &value_ll, default_value);
     if ((value_ll < 0) || (value_ll > UINT32_MAX)) {
         value_ll = default_value;
         if (rc == pcmk_rc_ok) {
             rc = pcmk_rc_bad_input;
         }
     }
 
     if (dest != NULL) {
         *dest = (uint32_t) value_ll;
     }
     return rc;
 }
 
 /*!
  * \brief Retrieve the long long integer value of an XML attribute
  *
  * This is like \c crm_element_value() but getting the value as a long long int.
  *
  * \param[in]  data  XML node to check
  * \param[in]  name  Attribute name to check
  * \param[out] dest  Where to store element value
  *
  * \return 0 on success, -1 otherwise
  */
 int
 crm_element_value_ll(const xmlNode *data, const char *name, long long *dest)
 {
     const char *value = NULL;
 
     CRM_CHECK(dest != NULL, return -1);
     value = crm_element_value(data, name);
     if (value != NULL) {
         int rc = pcmk__scan_ll(value, dest, PCMK__PARSE_INT_DEFAULT);
 
         if (rc == pcmk_rc_ok) {
             return 0;
         }
         crm_warn("Using default for %s "
                  "because '%s' is not a valid integer: %s",
                  name, value, pcmk_rc_str(rc));
     }
     return -1;
 }
 
 /*!
  * \brief Retrieve the millisecond value of an XML attribute
  *
  * This is like \c crm_element_value() but returning the value as a guint.
  *
  * \param[in]  data   XML node to check
  * \param[in]  name   Attribute name to check
  * \param[out] dest   Where to store attribute value
  *
  * \return \c pcmk_ok on success, -1 otherwise
  */
 int
 crm_element_value_ms(const xmlNode *data, const char *name, guint *dest)
 {
     const char *value = NULL;
     long long value_ll;
     int rc = pcmk_rc_ok;
 
     CRM_CHECK(dest != NULL, return -1);
     *dest = 0;
     value = crm_element_value(data, name);
     rc = pcmk__scan_ll(value, &value_ll, 0LL);
     if (rc != pcmk_rc_ok) {
         crm_warn("Using default for %s "
                  "because '%s' is not valid milliseconds: %s",
                  name, value, pcmk_rc_str(rc));
         return -1;
     }
     if ((value_ll < 0) || (value_ll > G_MAXUINT)) {
         crm_warn("Using default for %s because '%s' is out of range",
                  name, value);
         return -1;
     }
     *dest = (guint) value_ll;
     return pcmk_ok;
 }
 
 /*!
  * \brief Retrieve the seconds-since-epoch value of an XML attribute
  *
  * This is like \c crm_element_value() but returning the value as a time_t.
  *
  * \param[in]  xml    XML node to check
  * \param[in]  name   Attribute name to check
  * \param[out] dest   Where to store attribute value
  *
  * \return \c pcmk_ok on success, -1 otherwise
  */
 int
 crm_element_value_epoch(const xmlNode *xml, const char *name, time_t *dest)
 {
     long long value_ll = 0;
 
     if (crm_element_value_ll(xml, name, &value_ll) < 0) {
         return -1;
     }
 
     /* Unfortunately, we can't do any bounds checking, since time_t has neither
      * standardized bounds nor constants defined for them.
      */
     *dest = (time_t) value_ll;
     return pcmk_ok;
 }
 
 /*!
  * \brief Retrieve the value of XML second/microsecond attributes as time
  *
  * This is like \c crm_element_value() but returning value as a struct timeval.
  *
  * \param[in]  xml        XML to parse
  * \param[in]  name_sec   Name of XML attribute for seconds
  * \param[in]  name_usec  Name of XML attribute for microseconds
  * \param[out] dest       Where to store result
  *
  * \return \c pcmk_ok on success, -errno on error
  * \note Values default to 0 if XML or XML attribute does not exist
  */
 int
 crm_element_value_timeval(const xmlNode *xml, const char *name_sec,
                           const char *name_usec, struct timeval *dest)
 {
     long long value_i = 0;
 
     CRM_CHECK(dest != NULL, return -EINVAL);
     dest->tv_sec = 0;
     dest->tv_usec = 0;
 
     if (xml == NULL) {
         return pcmk_ok;
     }
 
     /* Unfortunately, we can't do any bounds checking, since there are no
      * constants provided for the bounds of time_t and suseconds_t, and
      * calculating them isn't worth the effort. If there are XML values
      * beyond the native sizes, there will probably be worse problems anyway.
      */
 
     // Parse seconds
     errno = 0;
     if (crm_element_value_ll(xml, name_sec, &value_i) < 0) {
         return -errno;
     }
     dest->tv_sec = (time_t) value_i;
 
     // Parse microseconds
     if (crm_element_value_ll(xml, name_usec, &value_i) < 0) {
         return -errno;
     }
     dest->tv_usec = (suseconds_t) value_i;
 
     return pcmk_ok;
 }
 
 /*!
  * \internal
  * \brief Get a date/time object from an XML attribute value
  *
  * \param[in]  xml   XML with attribute to parse (from CIB)
  * \param[in]  attr  Name of attribute to parse
  * \param[out] t     Where to create date/time object
  *                   (\p *t must be NULL initially)
  *
  * \return Standard Pacemaker return code
  * \note The caller is responsible for freeing \p *t using crm_time_free().
  */
 int
 pcmk__xe_get_datetime(const xmlNode *xml, const char *attr, crm_time_t **t)
 {
     const char *value = NULL;
 
     if ((t == NULL) || (*t != NULL) || (xml == NULL) || (attr == NULL)) {
         return EINVAL;
     }
 
     value = crm_element_value(xml, attr);
     if (value != NULL) {
         *t = crm_time_new(value);
         if (*t == NULL) {
             return pcmk_rc_unpack_error;
         }
     }
     return pcmk_rc_ok;
 }
 
 /*!
  * \brief Retrieve a copy of the value of an XML attribute
  *
  * This is like \c crm_element_value() but allocating new memory for the result.
  *
  * \param[in] data   XML node to check
  * \param[in] name   Attribute name to check
  *
  * \return Value of specified attribute (may be \c NULL)
  * \note The caller is responsible for freeing the result.
  */
 char *
 crm_element_value_copy(const xmlNode *data, const char *name)
 {
     return pcmk__str_copy(crm_element_value(data, name));
 }
 
 /*!
  * \internal
  * \brief Add a boolean attribute to an XML node.
  *
  * \param[in,out] node  XML node to add attributes to
  * \param[in]     name  XML attribute to create
  * \param[in]     value Value to give to the attribute
  */
 void
 pcmk__xe_set_bool_attr(xmlNodePtr node, const char *name, bool value)
 {
     crm_xml_add(node, name, pcmk__btoa(value));
 }
 
 /*!
  * \internal
  * \brief Extract a boolean attribute's value from an XML element, with
  *        error checking
  *
  * \param[in]  node  XML node to get attribute from
  * \param[in]  name  XML attribute to get
  * \param[out] value Destination for the value of the attribute
  *
  * \return EINVAL if \p name or \p value are NULL, ENODATA if \p node is
  *         NULL or the attribute does not exist, pcmk_rc_unknown_format
  *         if the attribute is not a boolean, and pcmk_rc_ok otherwise.
  *
  * \note \p value only has any meaning if the return value is pcmk_rc_ok.
  */
 int
 pcmk__xe_get_bool_attr(const xmlNode *node, const char *name, bool *value)
 {
     const char *xml_value = NULL;
     int ret, rc;
 
     if (node == NULL) {
         return ENODATA;
     } else if (name == NULL || value == NULL) {
         return EINVAL;
     }
 
     xml_value = crm_element_value(node, name);
 
     if (xml_value == NULL) {
         return ENODATA;
     }
 
     rc = crm_str_to_boolean(xml_value, &ret);
     if (rc == 1) {
         *value = ret;
         return pcmk_rc_ok;
     } else {
         return pcmk_rc_bad_input;
     }
 }
 
 /*!
  * \internal
  * \brief Extract a boolean attribute's value from an XML element
  *
  * \param[in] node XML node to get attribute from
  * \param[in] name XML attribute to get
  *
  * \return True if the given \p name is an attribute on \p node and has
  *         the value \c PCMK_VALUE_TRUE, False in all other cases
  */
 bool
 pcmk__xe_attr_is_true(const xmlNode *node, const char *name)
 {
     bool value = false;
     int rc;
 
     rc = pcmk__xe_get_bool_attr(node, name, &value);
     return rc == pcmk_rc_ok && value == true;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <glib.h>                       // gboolean, GSList
 
 #include <crm/common/nvpair_compat.h>   // pcmk_xml_attrs2nvpairs(), etc.
 #include <crm/common/xml_compat.h>      // crm_xml_sanitize_id()
 #include <crm/common/xml_element_compat.h>
 
 xmlNode *
 expand_idref(xmlNode *input, xmlNode *top)
 {
     return pcmk__xe_resolve_idref(input, top);
 }
 
 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);
     pcmk__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, NULL)) {
 
         if (recursive) {
             sorted_xml(child, result, recursive);
         } else {
             pcmk__xml_copy(result, child);
         }
     }
 
     return result;
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/tools/crm_diff.c b/tools/crm_diff.c
index 6fb5a1fb9f..27eb6d155c 100644
--- a/tools/crm_diff.c
+++ b/tools/crm_diff.c
@@ -1,338 +1,336 @@
 /*
- * Copyright 2005-2024 the Pacemaker project contributors
+ * Copyright 2005-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <sys/param.h>
 #include <sys/types.h>
 
 #include <crm/crm.h>
 #include <crm/common/cmdline_internal.h>
 #include <crm/common/output_internal.h>
 #include <crm/common/xml.h>
 #include <crm/common/ipc.h>
 #include <crm/cib.h>
 
 #define SUMMARY "Compare two Pacemaker configurations (in XML format) to produce a custom diff-like output, " \
                 "or apply such an output as a patch"
 
 struct {
     gboolean apply;
     gboolean as_cib;
     gboolean no_version;
     gboolean raw_original;
     gboolean raw_new;
     gboolean use_stdin;
     char *xml_file_original;
     char *xml_file_new;
 } options;
 
 gboolean new_string_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error);
 gboolean original_string_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error);
 gboolean patch_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error);
 
 static GOptionEntry original_xml_entries[] = {
     { "original", 'o', 0, G_OPTION_ARG_STRING, &options.xml_file_original,
       "XML is contained in the named file",
       "FILE" },
     { "original-string", 'O', 0, G_OPTION_ARG_CALLBACK, original_string_cb,
       "XML is contained in the supplied string",
       "STRING" },
 
     { NULL }
 };
 
 static GOptionEntry operation_entries[] = {
     { "new", 'n', 0, G_OPTION_ARG_STRING, &options.xml_file_new,
       "Compare the original XML to the contents of the named file",
       "FILE" },
     { "new-string", 'N', 0, G_OPTION_ARG_CALLBACK, new_string_cb,
       "Compare the original XML with the contents of the supplied string",
       "STRING" },
     { "patch", 'p', 0, G_OPTION_ARG_CALLBACK, patch_cb,
       "Patch the original XML with the contents of the named file",
       "FILE" },
 
     { NULL }
 };
 
 static GOptionEntry addl_entries[] = {
     { "cib", 'c', 0, G_OPTION_ARG_NONE, &options.as_cib,
       "Compare/patch the inputs as a CIB (includes versions details)",
       NULL },
     { "stdin", 's', 0, G_OPTION_ARG_NONE, &options.use_stdin,
       "",
       NULL },
     { "no-version", 'u', 0, G_OPTION_ARG_NONE, &options.no_version,
       "Generate the difference without versions details",
       NULL },
 
     { NULL }
 };
 
 gboolean
 new_string_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error) {
     options.raw_new = TRUE;
     pcmk__str_update(&options.xml_file_new, optarg);
     return TRUE;
 }
 
 gboolean
 original_string_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error) {
     options.raw_original = TRUE;
     pcmk__str_update(&options.xml_file_original, optarg);
     return TRUE;
 }
 
 gboolean
 patch_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **error) {
     options.apply = TRUE;
     pcmk__str_update(&options.xml_file_new, optarg);
     return TRUE;
 }
 
 static void
 print_patch(xmlNode *patch)
 {
     GString *buffer = g_string_sized_new(1024);
 
     pcmk__xml_string(patch, pcmk__xml_fmt_pretty, buffer, 0);
 
     printf("%s", buffer->str);
     g_string_free(buffer, TRUE);
     fflush(stdout);
 }
 
 // \return Standard Pacemaker return code
 static int
 apply_patch(xmlNode *input, xmlNode *patch, gboolean as_cib)
 {
     xmlNode *output = pcmk__xml_copy(NULL, input);
     int rc = xml_apply_patchset(output, patch, as_cib);
 
     rc = pcmk_legacy2rc(rc);
     if (rc != pcmk_rc_ok) {
         fprintf(stderr, "Could not apply patch: %s\n", pcmk_rc_str(rc));
         pcmk__xml_free(output);
         return rc;
     }
 
     if (output != NULL) {
         char *buffer;
 
         print_patch(output);
 
         buffer = pcmk__digest_xml(output, true);
         crm_trace("Digest: %s", pcmk__s(buffer, "<null>\n"));
         free(buffer);
         pcmk__xml_free(output);
     }
     return pcmk_rc_ok;
 }
 
 static void
 log_patch_cib_versions(xmlNode *patch)
 {
     int add[] = { 0, 0, 0 };
     int del[] = { 0, 0, 0 };
 
     const char *fmt = NULL;
     const char *digest = NULL;
 
     xml_patch_versions(patch, add, del);
     fmt = crm_element_value(patch, PCMK_XA_FORMAT);
     digest = crm_element_value(patch, PCMK__XA_DIGEST);
 
     if (add[2] != del[2] || add[1] != del[1] || add[0] != del[0]) {
         crm_info("Patch: --- %d.%d.%d %s", del[0], del[1], del[2], fmt);
         crm_info("Patch: +++ %d.%d.%d %s", add[0], add[1], add[2], digest);
     }
 }
 
 // \return Standard Pacemaker return code
 static int
 generate_patch(xmlNode *object_original, xmlNode *object_new, const char *xml_file_new,
                gboolean as_cib, gboolean no_version)
 {
     const char *vfields[] = {
         PCMK_XA_ADMIN_EPOCH,
         PCMK_XA_EPOCH,
         PCMK_XA_NUM_UPDATES,
     };
 
     xmlNode *output = NULL;
 
     /* If we're ignoring the version, make the version information
      * identical, so it isn't detected as a change. */
     if (no_version) {
         int lpc;
 
         for (lpc = 0; lpc < PCMK__NELEM(vfields); lpc++) {
             crm_copy_xml_element(object_original, object_new, vfields[lpc]);
         }
     }
 
-    xml_track_changes(object_new, NULL, object_new, FALSE);
-    if(as_cib) {
-        xml_calculate_significant_changes(object_original, object_new);
-    } else {
-        xml_calculate_changes(object_original, object_new);
+    if (as_cib) {
+        pcmk__xml_doc_set_flags(object_new->doc, pcmk__xf_ignore_attr_pos);
     }
+    pcmk__xml_mark_changes(object_original, object_new);
     crm_log_xml_debug(object_new, (xml_file_new? xml_file_new: "target"));
 
     output = xml_create_patchset(0, object_original, object_new, NULL, FALSE);
 
     pcmk__log_xml_changes(LOG_INFO, object_new);
-    xml_accept_changes(object_new);
+    pcmk__xml_commit_changes(object_new->doc);
 
     if (output == NULL) {
         return pcmk_rc_ok;  // No changes
     }
 
     patchset_process_digest(output, object_original, object_new, as_cib);
 
     if (as_cib) {
         log_patch_cib_versions(output);
 
     } else if (no_version) {
         pcmk__xml_free(pcmk__xe_first_child(output, PCMK_XE_VERSION, NULL,
                                             NULL));
     }
 
     pcmk__log_xml_patchset(LOG_NOTICE, output);
     print_patch(output);
     pcmk__xml_free(output);
 
     /* pcmk_rc_error means there's a non-empty diff.
      * @COMPAT Choose a more descriptive return code, like one that maps to
      * CRM_EX_DIGEST?
      */
     return pcmk_rc_error;
 }
 
 static GOptionContext *
 build_arg_context(pcmk__common_args_t *args) {
     GOptionContext *context = NULL;
 
     const char *description = "Examples:\n\n"
                               "Obtain the two different configuration files by running cibadmin on the two cluster setups to compare:\n\n"
                               "\t# cibadmin --query > cib-old.xml\n\n"
                               "\t# cibadmin --query > cib-new.xml\n\n"
                               "Calculate and save the difference between the two files:\n\n"
                               "\t# crm_diff --original cib-old.xml --new cib-new.xml > patch.xml\n\n"
                               "Apply the patch to the original file:\n\n"
                               "\t# crm_diff --original cib-old.xml --patch patch.xml > updated.xml\n\n"
                               "Apply the patch to the running cluster:\n\n"
                               "\t# cibadmin --patch -x patch.xml\n";
 
     context = pcmk__build_arg_context(args, NULL, NULL, NULL);
     g_option_context_set_description(context, description);
 
     pcmk__add_arg_group(context, "xml", "Original XML:",
                         "Show original XML options", original_xml_entries);
     pcmk__add_arg_group(context, "operation", "Operation:",
                         "Show operation options", operation_entries);
     pcmk__add_arg_group(context, "additional", "Additional Options:",
                         "Show additional options", addl_entries);
     return context;
 }
 
 int
 main(int argc, char **argv)
 {
     xmlNode *object_original = NULL;
     xmlNode *object_new = NULL;
 
     crm_exit_t exit_code = CRM_EX_OK;
     GError *error = NULL;
 
     pcmk__common_args_t *args = pcmk__new_common_args(SUMMARY);
     gchar **processed_args = pcmk__cmdline_preproc(argv, "nopNO");
     GOptionContext *context = build_arg_context(args);
 
     int rc = pcmk_rc_ok;
 
     if (!g_option_context_parse_strv(context, &processed_args, &error)) {
         exit_code = CRM_EX_USAGE;
         goto done;
     }
 
     pcmk__cli_init_logging("crm_diff", args->verbosity);
 
     if (args->version) {
         g_strfreev(processed_args);
         pcmk__free_arg_context(context);
         /* FIXME:  When crm_diff is converted to use formatted output, this can go. */
         pcmk__cli_help('v');
     }
 
     if (options.apply && options.no_version) {
         fprintf(stderr, "warning: -u/--no-version ignored with -p/--patch\n");
     } else if (options.as_cib && options.no_version) {
         fprintf(stderr, "error: -u/--no-version incompatible with -c/--cib\n");
         exit_code = CRM_EX_USAGE;
         goto done;
     }
 
     if (options.raw_original) {
         object_original = pcmk__xml_parse(options.xml_file_original);
 
     } else if (options.use_stdin) {
         fprintf(stderr, "Input first XML fragment:");
         object_original = pcmk__xml_read(NULL);
 
     } else if (options.xml_file_original != NULL) {
         object_original = pcmk__xml_read(options.xml_file_original);
     }
 
     if (options.raw_new) {
         object_new = pcmk__xml_parse(options.xml_file_new);
 
     } else if (options.use_stdin) {
         fprintf(stderr, "Input second XML fragment:");
         object_new = pcmk__xml_read(NULL);
 
     } else if (options.xml_file_new != NULL) {
         object_new = pcmk__xml_read(options.xml_file_new);
     }
 
     if (object_original == NULL) {
         fprintf(stderr, "Could not parse the first XML fragment\n");
         exit_code = CRM_EX_DATAERR;
         goto done;
     }
     if (object_new == NULL) {
         fprintf(stderr, "Could not parse the second XML fragment\n");
         exit_code = CRM_EX_DATAERR;
         goto done;
     }
 
     if (options.apply) {
         rc = apply_patch(object_original, object_new, options.as_cib);
     } else {
         rc = generate_patch(object_original, object_new, options.xml_file_new, options.as_cib, options.no_version);
     }
     exit_code = pcmk_rc2exitc(rc);
 
 done:
     g_strfreev(processed_args);
     pcmk__free_arg_context(context);
     free(options.xml_file_original);
     free(options.xml_file_new);
     pcmk__xml_free(object_original);
     pcmk__xml_free(object_new);
 
     pcmk__output_and_clear_error(&error, NULL);
     crm_exit(exit_code);
 }
diff --git a/tools/crm_shadow.c b/tools/crm_shadow.c
index d28b2e7c83..823d3bc4b1 100644
--- a/tools/crm_shadow.c
+++ b/tools/crm_shadow.c
@@ -1,1308 +1,1307 @@
 /*
  * Copyright 2004-2025 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <stdio.h>
 #include <unistd.h>
 
 #include <sys/param.h>
 #include <crm/crm.h>
 #include <sys/stat.h>
 #include <sys/types.h>
 
 #include <stdlib.h>
 #include <errno.h>
 #include <fcntl.h>
 
 #include <crm/common/cmdline_internal.h>
 #include <crm/common/ipc.h>
 #include <crm/common/output_internal.h>
 #include <crm/common/xml.h>
 
 #include <crm/cib.h>
 #include <crm/cib/internal.h>
 
 #define SUMMARY "perform Pacemaker configuration changes in a sandbox\n\n"  \
                 "This command sets up an environment in which "             \
                 "configuration tools (cibadmin,\n"                          \
                 "crm_resource, etc.) work offline instead of against a "    \
                 "live cluster, allowing\n"                                  \
                 "changes to be previewed and tested for side effects."
 
 #define INDENT "                              "
 
 enum shadow_command {
     shadow_cmd_none = 0,
     shadow_cmd_which,
     shadow_cmd_display,
     shadow_cmd_diff,
     shadow_cmd_file,
     shadow_cmd_create,
     shadow_cmd_create_empty,
     shadow_cmd_commit,
     shadow_cmd_delete,
     shadow_cmd_edit,
     shadow_cmd_reset,
     shadow_cmd_switch,
 };
 
 /*!
  * \internal
  * \brief Bit flags to control which fields of shadow CIB info are displayed
  *
  * \note Ignored for XML output.
  */
 enum shadow_disp_flags {
     shadow_disp_instance = (1 << 0),
     shadow_disp_file     = (1 << 1),
     shadow_disp_content  = (1 << 2),
     shadow_disp_diff     = (1 << 3),
 };
 
 static crm_exit_t exit_code = CRM_EX_OK;
 
 static struct {
     enum shadow_command cmd;
     int cmd_options;
     char *instance;
     gboolean force;
     gboolean batch;
     gboolean full_upload;
     gchar *validate_with;
 } options = {
     .cmd_options = cib_sync_call,
 };
 
 /*!
  * \internal
  * \brief Display an instruction to the user
  *
  * \param[in,out] out   Output object
  * \param[in]     args  Message-specific arguments
  *
  * \return Standard Pacemaker return code
  *
  * \note \p args should contain the following:
  *       -# Instructional message
  */
 PCMK__OUTPUT_ARGS("instruction", "const char *")
 static int
 instruction_default(pcmk__output_t *out, va_list args)
 {
     const char *msg = va_arg(args, const char *);
 
     if (msg == NULL) {
         return pcmk_rc_no_output;
     }
     return out->info(out, "%s", msg);
 }
 
 /*!
  * \internal
  * \brief Display an instruction to the user
  *
  * \param[in,out] out   Output object
  * \param[in]     args  Message-specific arguments
  *
  * \return Standard Pacemaker return code
  *
  * \note \p args should contain the following:
  *       -# Instructional message
  */
 PCMK__OUTPUT_ARGS("instruction", "const char *")
 static int
 instruction_xml(pcmk__output_t *out, va_list args)
 {
     const char *msg = va_arg(args, const char *);
 
     if (msg == NULL) {
         return pcmk_rc_no_output;
     }
     pcmk__output_create_xml_text_node(out, "instruction", msg);
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Display information about a shadow CIB instance
  *
  * \param[in,out] out   Output object
  * \param[in]     args  Message-specific arguments
  *
  * \return Standard Pacemaker return code
  *
  * \note \p args should contain the following:
  *       -# Instance name (can be \p NULL)
  *       -# Shadow file name (can be \p NULL)
  *       -# Shadow file content (can be \p NULL)
  *       -# Patchset containing the changes in the shadow CIB (can be \p NULL)
  *       -# Group of \p shadow_disp_flags indicating which fields to display
  */
 PCMK__OUTPUT_ARGS("shadow", "const char *", "const char *", "const xmlNode *",
                   "const xmlNode *", "enum shadow_disp_flags")
 static int
 shadow_default(pcmk__output_t *out, va_list args)
 {
     const char *instance = va_arg(args, const char *);
     const char *filename = va_arg(args, const char *);
     const xmlNode *content = va_arg(args, const xmlNode *);
     const xmlNode *diff = va_arg(args, const xmlNode *);
     enum shadow_disp_flags flags = (enum shadow_disp_flags) va_arg(args, int);
 
     int rc = pcmk_rc_no_output;
 
     if (pcmk_is_set(flags, shadow_disp_instance)) {
         rc = out->info(out, "Instance: %s", pcmk__s(instance, "<unknown>"));
     }
     if (pcmk_is_set(flags, shadow_disp_file)) {
         rc = out->info(out, "File name: %s", pcmk__s(filename, "<unknown>"));
     }
     if (pcmk_is_set(flags, shadow_disp_content)) {
         rc = out->info(out, "Content:");
 
         if (content != NULL) {
             GString *buf = g_string_sized_new(1024);
             gchar *str = NULL;
 
             pcmk__xml_string(content, pcmk__xml_fmt_pretty|pcmk__xml_fmt_text,
                              buf, 0);
 
             str = g_string_free(buf, FALSE);
             str = pcmk__trim(str);
             if (!pcmk__str_empty(str)) {
                 out->info(out, "%s", str);
             }
             g_free(str);
 
         } else {
             out->info(out, "<unknown>");
         }
     }
     if (pcmk_is_set(flags, shadow_disp_diff)) {
         rc = out->info(out, "Diff:");
 
         if (diff != NULL) {
             out->message(out, "xml-patchset", diff);
         } else {
             out->info(out, "<empty>");
         }
     }
 
     return rc;
 }
 
 /*!
  * \internal
  * \brief Display information about a shadow CIB instance
  *
  * \param[in,out] out   Output object
  * \param[in]     args  Message-specific arguments
  *
  * \return Standard Pacemaker return code
  *
  * \note \p args should contain the following:
  *       -# Instance name (can be \p NULL)
  *       -# Shadow file name (can be \p NULL)
  *       -# Shadow file content (can be \p NULL)
  *       -# Patchset containing the changes in the shadow CIB (can be \p NULL)
  *       -# Group of \p shadow_disp_flags indicating which fields to display
  */
 PCMK__OUTPUT_ARGS("shadow", "const char *", "const char *", "const xmlNode *",
                   "const xmlNode *", "enum shadow_disp_flags")
 static int
 shadow_text(pcmk__output_t *out, va_list args)
 {
     if (!out->is_quiet(out)) {
         return shadow_default(out, args);
 
     } else {
         const char *instance = va_arg(args, const char *);
         const char *filename = va_arg(args, const char *);
         const xmlNode *content = va_arg(args, const xmlNode *);
         const xmlNode *diff = va_arg(args, const xmlNode *);
         enum shadow_disp_flags flags = (enum shadow_disp_flags) va_arg(args, int);
 
         int rc = pcmk_rc_no_output;
         bool quiet_orig = out->quiet;
 
         /* We have to disable quiet mode for the "xml-patchset" message if we
          * call it, so we might as well do so for this whole section.
          */
         out->quiet = false;
 
         if (pcmk_is_set(flags, shadow_disp_instance) && (instance != NULL)) {
             rc = out->info(out, "%s", instance);
         }
         if (pcmk_is_set(flags, shadow_disp_file) && (filename != NULL)) {
             rc = out->info(out, "%s", filename);
         }
         if (pcmk_is_set(flags, shadow_disp_content) && (content != NULL)) {
             GString *buf = g_string_sized_new(1024);
             gchar *str = NULL;
 
             pcmk__xml_string(content, pcmk__xml_fmt_pretty|pcmk__xml_fmt_text,
                              buf, 0);
 
             str = g_string_free(buf, FALSE);
             str = pcmk__trim(str);
             rc = out->info(out, "%s", str);
             g_free(str);
         }
         if (pcmk_is_set(flags, shadow_disp_diff) && (diff != NULL)) {
             rc = out->message(out, "xml-patchset", diff);
         }
 
         out->quiet = quiet_orig;
         return rc;
     }
 }
 
 /*!
  * \internal
  * \brief Display information about a shadow CIB instance
  *
  * \param[in,out] out   Output object
  * \param[in]     args  Message-specific arguments
  *
  * \return Standard Pacemaker return code
  *
  * \note \p args should contain the following:
  *       -# Instance name (can be \p NULL)
  *       -# Shadow file name (can be \p NULL)
  *       -# Shadow file content (can be \p NULL)
  *       -# Patchset containing the changes in the shadow CIB (can be \p NULL)
  *       -# Group of \p shadow_disp_flags indicating which fields to display
  *          (ignored)
  */
 PCMK__OUTPUT_ARGS("shadow", "const char *", "const char *", "const xmlNode *",
                   "const xmlNode *", "enum shadow_disp_flags")
 static int
 shadow_xml(pcmk__output_t *out, va_list args)
 {
     const char *instance = va_arg(args, const char *);
     const char *filename = va_arg(args, const char *);
     const xmlNode *content = va_arg(args, const xmlNode *);
     const xmlNode *diff = va_arg(args, const xmlNode *);
     enum shadow_disp_flags flags G_GNUC_UNUSED =
         (enum shadow_disp_flags) va_arg(args, int);
 
     pcmk__output_xml_create_parent(out, PCMK_XE_SHADOW,
                                    PCMK_XA_INSTANCE, instance,
                                    PCMK_XA_FILE, filename,
                                    NULL);
 
     if (content != NULL) {
         GString *buf = g_string_sized_new(1024);
 
         pcmk__xml_string(content, pcmk__xml_fmt_pretty|pcmk__xml_fmt_text, buf,
                          0);
 
         out->output_xml(out, PCMK_XE_CONTENT, buf->str);
         g_string_free(buf, TRUE);
     }
 
     if (diff != NULL) {
         out->message(out, "xml-patchset", diff);
     }
 
     pcmk__output_xml_pop_parent(out);
     return pcmk_rc_ok;
 }
 
 static const pcmk__supported_format_t formats[] = {
     PCMK__SUPPORTED_FORMAT_NONE,
     PCMK__SUPPORTED_FORMAT_TEXT,
     PCMK__SUPPORTED_FORMAT_XML,
     { NULL, NULL, NULL }
 };
 
 static const pcmk__message_entry_t fmt_functions[] = {
     { "instruction", "default", instruction_default },
     { "instruction", "xml", instruction_xml },
     { "shadow", "default", shadow_default },
     { "shadow", "text", shadow_text },
     { "shadow", "xml", shadow_xml },
 
     { NULL, NULL, NULL }
 };
 
 /*!
  * \internal
  * \brief Set the error when \p --force is not passed with a dangerous command
  *
  * \param[in]  reason         Why command is dangerous
  * \param[in]  for_shadow     If true, command is dangerous to the shadow file.
  *                            Otherwise, command is dangerous to the active
  *                            cluster.
  * \param[in]  show_mismatch  If true and the supplied shadow instance is not
  *                            the same as the active shadow instance, report
  *                            this
  * \param[out] error          Where to store error
  */
 static void
 set_danger_error(const char *reason, bool for_shadow, bool show_mismatch,
                  GError **error)
 {
     const char *active = getenv("CIB_shadow");
     char *full = NULL;
 
     if (show_mismatch
         && !pcmk__str_eq(active, options.instance, pcmk__str_null_matches)) {
 
         full = crm_strdup_printf("%s.\nAdditionally, the supplied shadow "
                                  "instance (%s) is not the same as the active "
                                  "one (%s)",
                                 reason, options.instance, active);
         reason = full;
     }
 
     g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                 "%s%sTo prevent accidental destruction of the %s, the --force "
                 "flag is required in order to proceed.",
                 pcmk__s(reason, ""), ((reason != NULL)? ".\n" : ""),
                 (for_shadow? "shadow file" : "cluster"));
     free(full);
 }
 
 /*!
  * \internal
  * \brief Get the active shadow instance from the environment
  *
  * This sets \p options.instance to the value of the \p CIB_shadow env variable.
  *
  * \param[out] error  Where to store error
  */
 static int
 get_instance_from_env(GError **error)
 {
     int rc = pcmk_rc_ok;
 
     pcmk__str_update(&options.instance, getenv("CIB_shadow"));
     if (options.instance == NULL) {
         rc = ENXIO;
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "No active shadow configuration defined");
     }
     return rc;
 }
 
 /*!
  * \internal
  * \brief Validate that the shadow file does or does not exist, as appropriate
  *
  * \param[in]  filename      Absolute path of shadow file
  * \param[in]  should_exist  Whether the shadow file is expected to exist
  * \param[out] error         Where to store error
  *
  * \return Standard Pacemaker return code
  */
 static int
 check_file_exists(const char *filename, bool should_exist, GError **error)
 {
     struct stat buf;
 
     if (!should_exist && (stat(filename, &buf) == 0)) {
         char *reason = crm_strdup_printf("A shadow instance '%s' already "
                                          "exists", options.instance);
 
         exit_code = CRM_EX_CANTCREAT;
         set_danger_error(reason, true, false, error);
         free(reason);
         return EEXIST;
     }
 
     if (should_exist && (stat(filename, &buf) < 0)) {
         int rc = errno;
 
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not access shadow instance '%s': %s",
                     options.instance, strerror(rc));
         return errno;
     }
 
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Connect to the "real" (non-shadow) CIB
  *
  * \param[out] real_cib  Where to store CIB connection
  * \param[out] error     Where to store error
  *
  * \return Standard Pacemaker return code
  */
 static int
 connect_real_cib(cib_t **real_cib, GError **error)
 {
     int rc = pcmk_rc_ok;
 
     *real_cib = cib_new_no_shadow();
     if (*real_cib == NULL) {
         rc = ENOMEM;
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not create a CIB connection object");
         return rc;
     }
 
     rc = cib__signon_attempts(*real_cib, cib_command, 5);
     rc = pcmk_legacy2rc(rc);
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not connect to CIB: %s", pcmk_rc_str(rc));
     }
     return rc;
 }
 
 /*!
  * \internal
  * \brief Query the "real" (non-shadow) CIB and store the result
  *
  * \param[out]    output    Where to store query output
  * \param[out]    error     Where to store error
  *
  * \return Standard Pacemaker return code
  */
 static int
 query_real_cib(xmlNode **output, GError **error)
 {
     cib_t *real_cib = NULL;
     int rc = connect_real_cib(&real_cib, error);
 
     if (rc != pcmk_rc_ok) {
         goto done;
     }
 
     rc = real_cib->cmds->query(real_cib, NULL, output, options.cmd_options);
     rc = pcmk_legacy2rc(rc);
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not query the non-shadow CIB: %s", pcmk_rc_str(rc));
     }
 
 done:
     cib_delete(real_cib);
     return rc;
 }
 
 /*!
  * \internal
  * \brief Read XML from the given file
  *
  * \param[in]  filename  Path of input file
  * \param[out] output    Where to store XML read from \p filename
  * \param[out] error     Where to store error
  *
  * \return Standard Pacemaker return code
  */
 static int
 read_xml(const char *filename, xmlNode **output, GError **error)
 {
     int rc = pcmk_rc_ok;
 
     *output = pcmk__xml_read(filename);
     if (*output == NULL) {
         rc = pcmk_rc_no_input;
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not parse XML from input file '%s'", filename);
     }
     return rc;
 }
 
 /*!
  * \internal
  * \brief Write the shadow XML to a file
  *
  * \param[in]  xml       Shadow XML
  * \param[in]  filename  Name of destination file
  * \param[in]  reset     Whether the write is a reset (for logging only)
  * \param[out] error     Where to store error
  */
 static int
 write_shadow_file(const xmlNode *xml, const char *filename, bool reset,
                   GError **error)
 {
     int rc = pcmk__xml_write_file(xml, filename, false);
 
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not %s the shadow instance '%s': %s",
                     reset? "reset" : "create", options.instance,
                     pcmk_rc_str(rc));
     }
     return rc;
 }
 
 /*!
  * \internal
  * \brief Create a shell prompt based on the given shadow instance name
  *
  * \return Newly created prompt
  *
  * \note The caller is responsible for freeing the return value using \p free().
  */
 static inline char *
 get_shadow_prompt(void)
 {
     return crm_strdup_printf("shadow[%.40s] # ", options.instance);
 }
 
 /*!
  * \internal
  * \brief Set up environment variables for a shadow instance
  *
  * \param[in,out] out      Output object
  * \param[in]     do_switch  If true, switch to an existing instance (logging
  *                           only)
  * \param[out]    error      Where to store error
  */
 static void
 shadow_setup(pcmk__output_t *out, bool do_switch, GError **error)
 {
     const char *active = getenv("CIB_shadow");
     const char *prompt = getenv("PS1");
     const char *shell = getenv("SHELL");
     char *new_prompt = get_shadow_prompt();
 
     if (pcmk__str_eq(active, options.instance, pcmk__str_none)
         && pcmk__str_eq(new_prompt, prompt, pcmk__str_none)) {
         // CIB_shadow and prompt environment variables are already set up
         goto done;
     }
 
     if (!options.batch && (shell != NULL)) {
         out->info(out, "Setting up shadow instance");
         setenv("PS1", new_prompt, 1);
         setenv("CIB_shadow", options.instance, 1);
 
         out->message(out, PCMK_XE_INSTRUCTION,
                      "Press Ctrl+D to exit the crm_shadow shell");
 
         if (pcmk__str_eq(shell, "(^|/)bash$", pcmk__str_regex)) {
             execl(shell, shell, "--norc", "--noprofile", NULL);
         } else {
             execl(shell, shell, NULL);
         }
 
         exit_code = pcmk_rc2exitc(errno);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Failed to launch shell '%s': %s",
                     shell, pcmk_rc_str(errno));
 
     } else {
         char *msg = NULL;
         const char *prefix = "A new shadow instance was created. To begin "
                              "using it";
 
         if (do_switch) {
             prefix = "To switch to the named shadow instance";
         }
 
         msg = crm_strdup_printf("%s, enter the following into your shell:\n"
                                 "\texport CIB_shadow=%s",
                                 prefix, options.instance);
         out->message(out, "instruction", msg);
         free(msg);
     }
 
 done:
     free(new_prompt);
 }
 
 /*!
  * \internal
  * \brief Remind the user to clean up the shadow environment
  *
  * \param[in,out] out  Output object
  */
 static void
 shadow_teardown(pcmk__output_t *out)
 {
     const char *active = getenv("CIB_shadow");
     const char *prompt = getenv("PS1");
 
     if (pcmk__str_eq(active, options.instance, pcmk__str_none)) {
         char *our_prompt = get_shadow_prompt();
 
         if (pcmk__str_eq(prompt, our_prompt, pcmk__str_none)) {
             out->message(out, "instruction",
                          "Press Ctrl+D to exit the crm_shadow shell");
 
         } else {
             out->message(out, "instruction",
                          "Remember to unset the CIB_shadow variable by "
                          "entering the following into your shell:\n"
                          "\tunset CIB_shadow");
         }
         free(our_prompt);
     }
 }
 
 /*!
  * \internal
  * \brief Commit the shadow file contents to the active cluster
  *
  * \param[out] error  Where to store error
  */
 static void
 commit_shadow_file(GError **error)
 {
     char *filename = NULL;
     cib_t *real_cib = NULL;
 
     xmlNodePtr input = NULL;
     xmlNodePtr section_xml = NULL;
     const char *section = NULL;
 
     int rc = pcmk_rc_ok;
 
     if (!options.force) {
         const char *reason = "The commit command overwrites the active cluster "
                              "configuration";
 
         exit_code = CRM_EX_USAGE;
         set_danger_error(reason, false, true, error);
         return;
     }
 
     filename = get_shadow_file(options.instance);
     if (check_file_exists(filename, true, error) != pcmk_rc_ok) {
         goto done;
     }
 
     if (connect_real_cib(&real_cib, error) != pcmk_rc_ok) {
         goto done;
     }
 
     if (read_xml(filename, &input, error) != pcmk_rc_ok) {
         goto done;
     }
 
     section_xml = input;
 
     if (!options.full_upload) {
         section = PCMK_XE_CONFIGURATION;
         section_xml = pcmk__xe_first_child(input, section, NULL, NULL);
     }
 
     rc = real_cib->cmds->replace(real_cib, section, section_xml,
                                  options.cmd_options);
     rc = pcmk_legacy2rc(rc);
 
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not commit shadow instance '%s' to the CIB: %s",
                     options.instance, pcmk_rc_str(rc));
     }
 
 done:
     free(filename);
     cib_delete(real_cib);
     pcmk__xml_free(input);
 }
 
 /*!
  * \internal
  * \brief Create a new empty shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  *
  * \note If \p --force is given, we try to write the file regardless of whether
  *       it already exists.
  */
 static void
 create_shadow_empty(pcmk__output_t *out, GError **error)
 {
     char *filename = get_shadow_file(options.instance);
     xmlNode *output = NULL;
 
     if (!options.force
         && (check_file_exists(filename, false, error) != pcmk_rc_ok)) {
         goto done;
     }
 
     output = createEmptyCib(0);
     crm_xml_add(output, PCMK_XA_VALIDATE_WITH, options.validate_with);
     out->info(out, "Created new %s configuration",
               crm_element_value(output, PCMK_XA_VALIDATE_WITH));
 
     if (write_shadow_file(output, filename, false, error) != pcmk_rc_ok) {
         goto done;
     }
     shadow_setup(out, false, error);
 
 done:
     free(filename);
     pcmk__xml_free(output);
 }
 
 /*!
  * \internal
  * \brief Create a shadow instance based on the active CIB
  *
  * \param[in,out] out    Output object
  * \param[in]     reset  If true, overwrite the given existing shadow instance.
  *                       Otherwise, create a new shadow instance with the given
  *                       name.
  * \param[out]    error  Where to store error
  *
  * \note If \p --force is given, we try to write the file regardless of whether
  *       it already exists.
  */
 static void
 create_shadow_from_cib(pcmk__output_t *out, bool reset, GError **error)
 {
     char *filename = get_shadow_file(options.instance);
     xmlNode *output = NULL;
 
     if (!options.force) {
         if (reset) {
             const char *reason = "The reset command overwrites the active "
                                  "shadow configuration";
 
             exit_code = CRM_EX_USAGE;
             set_danger_error(reason, true, true, error);
             goto done;
         }
         if (check_file_exists(filename, reset, error) != pcmk_rc_ok) {
             goto done;
         }
     }
 
     if (query_real_cib(&output, error) != pcmk_rc_ok) {
         goto done;
     }
 
     if (write_shadow_file(output, filename, reset, error) != pcmk_rc_ok) {
         goto done;
     }
     shadow_setup(out, false, error);
 
 done:
     free(filename);
     pcmk__xml_free(output);
 }
 
 /*!
  * \internal
  * \brief Delete the shadow file
  *
  * \param[in,out] out  Output object
  * \param[out]    error  Where to store error
  */
 static void
 delete_shadow_file(pcmk__output_t *out, GError **error)
 {
     char *filename = NULL;
 
     if (!options.force) {
         const char *reason = "The delete command removes the specified shadow "
                              "file";
 
         exit_code = CRM_EX_USAGE;
         set_danger_error(reason, true, true, error);
         return;
     }
 
     filename = get_shadow_file(options.instance);
 
     if ((unlink(filename) < 0) && (errno != ENOENT)) {
         exit_code = pcmk_rc2exitc(errno);
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "Could not remove shadow instance '%s': %s",
                     options.instance, strerror(errno));
     } else {
         shadow_teardown(out);
     }
     free(filename);
 }
 
 /*!
  * \internal
  * \brief Open the shadow file in a text editor
  *
  * \param[out] error  Where to store error
  *
  * \note The \p EDITOR environment variable must be set.
  */
 static void
 edit_shadow_file(GError **error)
 {
     char *filename = NULL;
     const char *editor = NULL;
 
     if (get_instance_from_env(error) != pcmk_rc_ok) {
         return;
     }
 
     filename = get_shadow_file(options.instance);
     if (check_file_exists(filename, true, error) != pcmk_rc_ok) {
         goto done;
     }
 
     editor = getenv("EDITOR");
     if (editor == NULL) {
         exit_code = CRM_EX_NOT_CONFIGURED;
         g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                     "No value for EDITOR defined");
         goto done;
     }
 
     execlp(editor, "--", filename, NULL);
     exit_code = CRM_EX_OSFILE;
     g_set_error(error, PCMK__EXITC_ERROR, exit_code,
                 "Could not invoke EDITOR (%s %s): %s",
                 editor, filename, strerror(errno));
 
 done:
     free(filename);
 }
 
 /*!
  * \internal
  * \brief Show the contents of the active shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  */
 static void
 show_shadow_contents(pcmk__output_t *out, GError **error)
 {
     char *filename = NULL;
 
     if (get_instance_from_env(error) != pcmk_rc_ok) {
         return;
     }
 
     filename = get_shadow_file(options.instance);
 
     if (check_file_exists(filename, true, error) == pcmk_rc_ok) {
         xmlNode *output = NULL;
         bool quiet_orig = out->quiet;
 
         if (read_xml(filename, &output, error) != pcmk_rc_ok) {
             goto done;
         }
 
         out->quiet = true;
         out->message(out, "shadow",
                      options.instance, NULL, output, NULL, shadow_disp_content);
         out->quiet = quiet_orig;
 
         pcmk__xml_free(output);
     }
 
 done:
     free(filename);
 }
 
 /*!
  * \internal
  * \brief Show the changes in the active shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  */
 static void
 show_shadow_diff(pcmk__output_t *out, GError **error)
 {
     char *filename = NULL;
     xmlNodePtr old_config = NULL;
     xmlNodePtr new_config = NULL;
     xmlNodePtr diff = NULL;
     bool quiet_orig = out->quiet;
 
     if (get_instance_from_env(error) != pcmk_rc_ok) {
         return;
     }
 
     filename = get_shadow_file(options.instance);
     if (check_file_exists(filename, true, error) != pcmk_rc_ok) {
         goto done;
     }
 
     if (query_real_cib(&old_config, error) != pcmk_rc_ok) {
         goto done;
     }
 
     if (read_xml(filename, &new_config, error) != pcmk_rc_ok) {
         goto done;
     }
-    xml_track_changes(new_config, NULL, new_config, false);
-    xml_calculate_changes(old_config, new_config);
+    pcmk__xml_mark_changes(old_config, new_config);
     diff = xml_create_patchset(0, old_config, new_config, NULL, false);
 
     pcmk__log_xml_changes(LOG_INFO, new_config);
-    xml_accept_changes(new_config);
+    pcmk__xml_commit_changes(new_config->doc);
 
     out->quiet = true;
     out->message(out, "shadow",
                  options.instance, NULL, NULL, diff, shadow_disp_diff);
     out->quiet = quiet_orig;
 
     if (diff != NULL) {
         /* @COMPAT: Exit with CRM_EX_DIGEST? This is not really an error; we
          * just want to indicate that there are differences (as the diff command
          * does).
          */
         exit_code = CRM_EX_ERROR;
     }
 
 done:
     free(filename);
     pcmk__xml_free(old_config);
     pcmk__xml_free(new_config);
     pcmk__xml_free(diff);
 }
 
 /*!
  * \internal
  * \brief Show the absolute path of the active shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  */
 static void
 show_shadow_filename(pcmk__output_t *out, GError **error)
 {
     if (get_instance_from_env(error) == pcmk_rc_ok) {
         char *filename = get_shadow_file(options.instance);
         bool quiet_orig = out->quiet;
 
         out->quiet = true;
         out->message(out, "shadow",
                      options.instance, filename, NULL, NULL, shadow_disp_file);
         out->quiet = quiet_orig;
 
         free(filename);
     }
 }
 
 /*!
  * \internal
  * \brief Show the active shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  */
 static void
 show_shadow_instance(pcmk__output_t *out, GError **error)
 {
     if (get_instance_from_env(error) == pcmk_rc_ok) {
         bool quiet_orig = out->quiet;
 
         out->quiet = true;
         out->message(out, "shadow",
                      options.instance, NULL, NULL, NULL, shadow_disp_instance);
         out->quiet = quiet_orig;
     }
 }
 
 /*!
  * \internal
  * \brief Switch to the given shadow instance
  *
  * \param[in,out] out    Output object
  * \param[out]    error  Where to store error
  */
 static void
 switch_shadow_instance(pcmk__output_t *out, GError **error)
 {
     char *filename = NULL;
 
     filename = get_shadow_file(options.instance);
     if (check_file_exists(filename, true, error) == pcmk_rc_ok) {
         shadow_setup(out, true, error);
     }
     free(filename);
 }
 
 static gboolean
 command_cb(const gchar *option_name, const gchar *optarg, gpointer data,
            GError **error)
 {
     if (pcmk__str_any_of(option_name, "-w", "--which", NULL)) {
         options.cmd = shadow_cmd_which;
 
     } else if (pcmk__str_any_of(option_name, "-p", "--display", NULL)) {
         options.cmd = shadow_cmd_display;
 
     } else if (pcmk__str_any_of(option_name, "-d", "--diff", NULL)) {
         options.cmd = shadow_cmd_diff;
 
     } else if (pcmk__str_any_of(option_name, "-F", "--file", NULL)) {
         options.cmd = shadow_cmd_file;
 
     } else if (pcmk__str_any_of(option_name, "-c", "--create", NULL)) {
         options.cmd = shadow_cmd_create;
 
     } else if (pcmk__str_any_of(option_name, "-e", "--create-empty", NULL)) {
         options.cmd = shadow_cmd_create_empty;
 
     } else if (pcmk__str_any_of(option_name, "-C", "--commit", NULL)) {
         options.cmd = shadow_cmd_commit;
 
     } else if (pcmk__str_any_of(option_name, "-D", "--delete", NULL)) {
         options.cmd = shadow_cmd_delete;
 
     } else if (pcmk__str_any_of(option_name, "-E", "--edit", NULL)) {
         options.cmd = shadow_cmd_edit;
 
     } else if (pcmk__str_any_of(option_name, "-r", "--reset", NULL)) {
         options.cmd = shadow_cmd_reset;
 
     } else if (pcmk__str_any_of(option_name, "-s", "--switch", NULL)) {
         options.cmd = shadow_cmd_switch;
 
     } else {
         // Should be impossible
         return FALSE;
     }
 
     // optarg may be NULL and that's okay
     pcmk__str_update(&options.instance, optarg);
     return TRUE;
 }
 
 static GOptionEntry query_entries[] = {
     { "which", 'w', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Indicate the active shadow copy", NULL },
 
     { "display", 'p', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the contents of the active shadow copy", NULL },
 
     { "diff", 'd', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the changes in the active shadow copy", NULL },
 
     { "file", 'F', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the location of the active shadow copy file", NULL },
 
     { NULL }
 };
 
 static GOptionEntry command_entries[] = {
     { "create", 'c', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK, command_cb,
       "Create the named shadow copy of the active cluster configuration",
       "name" },
 
     { "create-empty", 'e', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK,
       command_cb,
       "Create the named shadow copy with an empty cluster configuration.\n"
       INDENT "Optional: --validate-with", "name" },
 
     { "commit", 'C', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK, command_cb,
       "Upload the contents of the named shadow copy to the cluster", "name" },
 
     { "delete", 'D', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK, command_cb,
       "Delete the contents of the named shadow copy", "name" },
 
     { "edit", 'E', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Edit the contents of the active shadow copy with your favorite $EDITOR",
       NULL },
 
     { "reset", 'r', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK, command_cb,
       "Recreate named shadow copy from the active cluster configuration",
       "name. Required: --force." },
 
     { "switch", 's', G_OPTION_FLAG_NONE, G_OPTION_ARG_CALLBACK, command_cb,
       "(Advanced) Switch to the named shadow copy", "name" },
 
     { NULL }
 };
 
 static GOptionEntry addl_entries[] = {
     { "force", 'f', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &options.force,
       "(Advanced) Force the action to be performed", NULL },
 
     { "batch", 'b', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &options.batch,
       "(Advanced) Don't spawn a new shell", NULL },
 
     { "all", 'a', G_OPTION_FLAG_NONE, G_OPTION_ARG_NONE, &options.full_upload,
       "(Advanced) Upload entire CIB, including status, with --commit", NULL },
 
     { "validate-with", 'v', G_OPTION_FLAG_NONE, G_OPTION_ARG_STRING,
       &options.validate_with,
       "(Advanced) Create an older configuration version", NULL },
 
     { NULL }
 };
 
 static GOptionContext *
 build_arg_context(pcmk__common_args_t *args, GOptionGroup **group)
 {
     const char *desc = NULL;
     GOptionContext *context = NULL;
 
     desc = "Examples:\n\n"
            "Create a blank shadow configuration:\n\n"
            "\t# crm_shadow --create-empty myShadow\n\n"
            "Create a shadow configuration from the running cluster\n\n"
            "\t# crm_shadow --create myShadow\n\n"
            "Display the current shadow configuration:\n\n"
            "\t# crm_shadow --display\n\n"
            "Discard the current shadow configuration (named myShadow):\n\n"
            "\t# crm_shadow --delete myShadow --force\n\n"
            "Upload current shadow configuration (named myShadow) to running "
            "cluster:\n\n"
            "\t# crm_shadow --commit myShadow\n\n";
 
     context = pcmk__build_arg_context(args, "text (default), xml", group,
                                       "<query>|<command>");
     g_option_context_set_description(context, desc);
 
     pcmk__add_arg_group(context, "queries", "Queries:",
                         "Show query help", query_entries);
     pcmk__add_arg_group(context, "commands", "Commands:",
                         "Show command help", command_entries);
     pcmk__add_arg_group(context, "additional", "Additional Options:",
                         "Show additional options", addl_entries);
     return context;
 }
 
 int
 main(int argc, char **argv)
 {
     int rc = pcmk_rc_ok;
     pcmk__output_t *out = NULL;
 
     GError *error = NULL;
 
     GOptionGroup *output_group = NULL;
     pcmk__common_args_t *args = pcmk__new_common_args(SUMMARY);
     gchar **processed_args = pcmk__cmdline_preproc(argv, "CDcersv");
     GOptionContext *context = build_arg_context(args, &output_group);
 
     crm_log_preinit(NULL, argc, argv);
 
     pcmk__register_formats(output_group, formats);
 
     if (!g_option_context_parse_strv(context, &processed_args, &error)) {
         exit_code = CRM_EX_USAGE;
         goto done;
     }
 
     rc = pcmk__output_new(&out, args->output_ty, args->output_dest, argv);
     if (rc != pcmk_rc_ok) {
         exit_code = CRM_EX_ERROR;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Error creating output format %s: %s", args->output_ty,
                     pcmk_rc_str(rc));
         goto done;
     }
 
     if (g_strv_length(processed_args) > 1) {
         gchar *help = g_option_context_get_help(context, TRUE, NULL);
         GString *extra = g_string_sized_new(128);
 
         for (int lpc = 1; processed_args[lpc] != NULL; lpc++) {
             if (extra->len > 0) {
                 g_string_append_c(extra, ' ');
             }
             g_string_append(extra, processed_args[lpc]);
         }
 
         exit_code = CRM_EX_USAGE;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "non-option ARGV-elements: %s\n\n%s", extra->str, help);
         g_free(help);
         g_string_free(extra, TRUE);
         goto done;
     }
 
     if (args->version) {
         out->version(out, false);
         goto done;
     }
 
     pcmk__register_messages(out, fmt_functions);
 
     if (options.cmd == shadow_cmd_none) {
         // @COMPAT: Create a default command if other tools have one
         gchar *help = g_option_context_get_help(context, TRUE, NULL);
 
         exit_code = CRM_EX_USAGE;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Must specify a query or command option\n\n%s", help);
         g_free(help);
         goto done;
     }
 
     pcmk__cli_init_logging("crm_shadow", args->verbosity);
 
     if (args->verbosity > 0) {
         cib__set_call_options(options.cmd_options, crm_system_name,
                               cib_verbose);
     }
 
     // Run the command
     switch (options.cmd) {
         case shadow_cmd_commit:
             commit_shadow_file(&error);
             break;
         case shadow_cmd_create:
             create_shadow_from_cib(out, false, &error);
             break;
         case shadow_cmd_create_empty:
             create_shadow_empty(out, &error);
             break;
         case shadow_cmd_reset:
             create_shadow_from_cib(out, true, &error);
             break;
         case shadow_cmd_delete:
             delete_shadow_file(out, &error);
             break;
         case shadow_cmd_diff:
             show_shadow_diff(out, &error);
             break;
         case shadow_cmd_display:
             show_shadow_contents(out, &error);
             break;
         case shadow_cmd_edit:
             edit_shadow_file(&error);
             break;
         case shadow_cmd_file:
             show_shadow_filename(out, &error);
             break;
         case shadow_cmd_switch:
             switch_shadow_instance(out, &error);
             break;
         case shadow_cmd_which:
             show_shadow_instance(out, &error);
             break;
         default:
             // Should never reach this point
             break;
     }
 
 done:
     g_strfreev(processed_args);
     pcmk__free_arg_context(context);
 
     pcmk__output_and_clear_error(&error, out);
 
     free(options.instance);
     g_free(options.validate_with);
 
     if (out != NULL) {
         out->finish(out, exit_code, true, NULL);
         pcmk__output_free(out);
     }
 
     crm_exit(exit_code);
 }