diff --git a/include/crm/common/xml_internal.h b/include/crm/common/xml_internal.h
index 8f087c4f75..3386194093 100644
--- a/include/crm/common/xml_internal.h
+++ b/include/crm/common/xml_internal.h
@@ -1,454 +1,463 @@
 /*
  * 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 */
 
 /*!
  * \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),
 
-    //! Tracking is enabled (set for document only)
+    /*!
+     * \brief Tracking is enabled (set for document only)
+     *
+     * \c pcmk__xml_commit_changes() should usually be called before setting
+     * this flag. Creating a node initializes its private data, which sets
+     * \c pcmk__xf_dirty and \c pcmk__xf_created on the node even if tracking is
+     * not enabled for the document. If we don't commit changes before enabling
+     * tracking, then earlier node creations might appear in the later set of
+     * changes.
+     */
     pcmk__xf_tracking        = (UINT32_C(1) << 4),
 
     //! Tree's changes compared to another XML tree have been marked
     pcmk__xf_processed       = (UINT32_C(1) << 5),
 
     //! 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_lazy            = (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);
 
 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 5dead76dd0..06c065bacf 100644
--- a/lib/cib/cib_utils.c
+++ b/lib/cib/cib_utils.c
@@ -1,942 +1,956 @@
 /*
  * Original copyright 2004 International Business Machines
  * 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 (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, enable_acl);
+        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, enable_acl);
+        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, *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, enable_acl);
+            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);
+            }
             xml_calculate_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);
     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/tests/xml_comment/pcmk__xc_create_test.c b/lib/common/tests/xml_comment/pcmk__xc_create_test.c
index de409d19ed..c9f0bdfbab 100644
--- a/lib/common/tests/xml_comment/pcmk__xc_create_test.c
+++ b/lib/common/tests/xml_comment/pcmk__xc_create_test.c
@@ -1,72 +1,72 @@
 /*
  * 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(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;
 
-    // Also clears existing doc flags
-    xml_track_changes((xmlNode *) doc, NULL, NULL, false);
+    pcmk__xml_commit_changes(doc);  // Clear pcmk__xf_dirty and pcmk__xf_created
+    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)
 {
     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/xml.c b/lib/common/xml.c
index 35c95ac19a..2d1d700725 100644
--- a/lib/common/xml.c
+++ b/lib/common/xml.c
@@ -1,1680 +1,1686 @@
 /*
  * 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 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 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)
 {
+    /* @TODO Is it necessary to set pcmk__xf_dirty and pcmk__xf_created flags on
+     * new nodes when change tracking is not even enabled? This seems to be the
+     * main reason why committing changes before enabling tracking (by setting
+     * pcmk__xf_tracking) is so often required.
+     */
     CRM_CHECK(node != NULL, return true);
 
     if (node->_private != NULL) {
         return true;
     }
 
     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);
 
                 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)) {
         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;
     }
 
     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);
     }
 }
 
 /*!
  * \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 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
 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 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.
  */
 void
 pcmk__xml_commit_changes(xmlDoc *doc)
 {
     xml_doc_private_t *docpriv = NULL;
 
     if (doc == NULL) {
         return;
     }
 
     docpriv = doc->_private;
     if (docpriv == NULL) {
         return;
     }
 
     if (pcmk_is_set(docpriv->flags, pcmk__xf_dirty)) {
         pcmk__xml_tree_foreach(xmlDocGetRootElement(doc), commit_attr_deletions,
                                NULL);
     }
     reset_xml_private_data(docpriv);
     docpriv->flags = pcmk__xf_none;
 }
 
 /*!
  * \internal
  * \brief Find first child XML node matching another given XML node
  *
  * \param[in] haystack  XML whose children should be checked
  * \param[in] needle    XML to match (comment content or element name and ID)
  * \param[in] exact     If true and needle is a comment, position must match
  */
 xmlNode *
 pcmk__xml_match(const xmlNode *haystack, const xmlNode *needle, bool exact)
 {
     CRM_CHECK(needle != NULL, return NULL);
 
     if (needle->type == XML_COMMENT_NODE) {
         return pcmk__xc_match(haystack, needle, exact);
 
     } else {
         const char *id = pcmk__xe_id(needle);
         const char *attr = (id == NULL)? NULL : PCMK_XA_ID;
 
         return pcmk__xe_first_child(haystack, (const char *) needle->name, attr,
                                     id);
     }
 }
 
 /*!
  * \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)
  */
 static void
 free_xml_with_position(xmlNode *node, int position)
 {
     xmlDoc *doc = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     if (node == NULL) {
         return;
     }
     doc = node->doc;
     nodepriv = node->_private;
 
     if ((doc != NULL) && (xmlDocGetRootElement(doc) == node)) {
         /* @TODO Should we check ACLs first? Otherwise it seems like we could
          * free the root element without write permission.
          */
         pcmk__xml_free_doc(doc);
         return;
     }
 
     if (!pcmk__check_acl(node, NULL, pcmk__xf_acl_write)) {
         GString *xpath = NULL;
 
         pcmk__if_tracing({}, return);
         xpath = pcmk__element_xpath(node);
         qb_log_from_external_source(__func__, __FILE__,
                                     "Cannot remove %s %x", LOG_TRACE,
                                     __LINE__, 0, xpath->str, nodepriv->flags);
         g_string_free(xpath, TRUE);
         return;
     }
 
     if (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);
 }
 
 /*!
  * \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 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_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.
                  */
                 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)
 {
     // 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
  *
  * When calculating XML changes, we need to know when a child element has been
  * deleted. Add the child back to the new XML, so that we can check the removal
  * against ACLs, and mark it as deleted for later removal after differences have
  * been calculated.
  *
  * \param[in,out] old_child    Child element from original XML
  * \param[in,out] new_parent   New XML to add marked copy to
  */
 static void
 mark_child_deleted(xmlNode *old_child, xmlNode *new_parent)
 {
     // Re-create the child element so we can check ACLs
     xmlNode *candidate = pcmk__xml_copy(new_parent, old_child);
 
     // Clear flags on new child and its children
     pcmk__xml_tree_foreach(candidate, pcmk__xml_reset_node_flags, NULL);
 
     // Check whether ACLs allow the deletion
     pcmk__apply_acl(xmlDocGetRootElement(candidate->doc));
 
     // Remove the child again (which will track it in document's deleted_objs)
     free_xml_with_position(candidate,
                            pcmk__xml_position(old_child, pcmk__xf_skip));
 
     if (pcmk__xml_match(new_parent, old_child, true) == NULL) {
         pcmk__set_xml_flags((xml_node_private_t *) (old_child->_private),
                             pcmk__xf_skip);
     }
 }
 
 static void
 mark_child_moved(xmlNode *old_child, xmlNode *new_parent, xmlNode *new_child,
                  int p_old, int p_new)
 {
     xml_node_private_t *nodepriv = new_child->_private;
 
     crm_trace("Child element %s with "
               PCMK_XA_ID "='%s' moved from position %d to %d under %s",
               new_child->name, pcmk__s(pcmk__xe_id(new_child), "<no id>"),
               p_old, p_new, new_parent->name);
     pcmk__mark_xml_node_dirty(new_parent);
     pcmk__set_xml_flags(nodepriv, pcmk__xf_moved);
 
     if (p_old > p_new) {
         nodepriv = old_child->_private;
     } else {
         nodepriv = new_child->_private;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 }
 
 // Given original and new XML, mark new XML portions that have changed
 static void
 mark_xml_changes(xmlNode *old_xml, xmlNode *new_xml, bool check_top)
 {
     xmlNode *old_child = NULL;
     xmlNode *new_child = NULL;
     xml_node_private_t *nodepriv = NULL;
 
     CRM_CHECK(new_xml != NULL, return);
     if (old_xml == NULL) {
         mark_xml_tree_dirty_created(new_xml);
         pcmk__apply_creation_acl(new_xml, check_top);
         return;
     }
 
     nodepriv = new_xml->_private;
     CRM_CHECK(nodepriv != NULL, return);
 
     if(nodepriv->flags & pcmk__xf_processed) {
         /* Avoid re-comparing nodes */
         return;
     }
     pcmk__set_xml_flags(nodepriv, pcmk__xf_processed);
 
     xml_diff_attrs(old_xml, new_xml);
 
     // Check for differences in the original children
     for (old_child = pcmk__xml_first_child(old_xml); old_child != NULL;
          old_child = pcmk__xml_next(old_child)) {
 
         new_child = pcmk__xml_match(new_xml, old_child, true);
 
         if (new_child != NULL) {
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             mark_child_deleted(old_child, new_xml);
         }
     }
 
     // Check for moved or created children
     new_child = pcmk__xml_first_child(new_xml);
     while (new_child != NULL) {
         xmlNode *next = pcmk__xml_next(new_child);
 
         old_child = pcmk__xml_match(old_xml, new_child, true);
 
         if (old_child == NULL) {
             // This is a newly created child
             nodepriv = new_child->_private;
             pcmk__set_xml_flags(nodepriv, pcmk__xf_skip);
 
             // May free new_child
             mark_xml_changes(old_child, new_child, true);
 
         } else {
             /* Check for movement, we already checked for differences */
             int p_new = pcmk__xml_position(new_child, pcmk__xf_skip);
             int p_old = pcmk__xml_position(old_child, pcmk__xf_skip);
 
             if(p_old != p_new) {
                 mark_child_moved(old_child, new_xml, new_child, p_old, p_new);
             }
         }
 
         new_child = next;
     }
 }
 
 void
 xml_calculate_significant_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     if (new_xml != NULL) {
         pcmk__xml_doc_set_flags(new_xml->doc, pcmk__xf_lazy);
     }
     xml_calculate_changes(old_xml, new_xml);
 }
 
 // Called functions may set the \p pcmk__xf_skip flag on parts of \p old_xml
 void
 xml_calculate_changes(xmlNode *old_xml, xmlNode *new_xml)
 {
     CRM_CHECK((old_xml != NULL) && (new_xml != NULL)
               && pcmk__xe_is(old_xml, (const char *) new_xml->name)
               && pcmk__str_eq(pcmk__xe_id(old_xml), pcmk__xe_id(new_xml),
                               pcmk__str_none),
               return);
 
     if (!pcmk__xml_doc_all_flags_set(new_xml->doc, pcmk__xf_tracking)) {
-        xml_track_changes(new_xml, NULL, NULL, FALSE);
+        pcmk__xml_commit_changes(new_xml->doc);
+        pcmk__xml_doc_set_flags(new_xml->doc, pcmk__xf_tracking);
     }
 
     mark_xml_changes(old_xml, new_xml, FALSE);
 }
 
 /*!
  * \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);
     }
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API