diff --git a/include/crm/common/xml_comment_internal.h b/include/crm/common/xml_comment_internal.h
new file mode 100644
index 0000000000..59f85591fe
--- /dev/null
+++ b/include/crm/common/xml_comment_internal.h
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#ifndef PCMK__CRM_COMMON_XML_COMMENT_INTERNAL__H
+#define PCMK__CRM_COMMON_XML_COMMENT_INTERNAL__H
+
+/*
+ * Internal-only wrappers for and extensions to libxml2 XML comment functions
+ */
+
+#include <libxml/tree.h>    // xmlDoc, xmlNode
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+xmlNode *pcmk__xc_create(xmlDoc *doc, const char *content);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  // PCMK__XML_COMMENT_INTERNAL__H
diff --git a/include/crm/common/xml_internal.h b/include/crm/common/xml_internal.h
index ccabd77b7a..869df348a0 100644
--- a/include/crm/common/xml_internal.h
+++ b/include/crm/common/xml_internal.h
@@ -1,613 +1,614 @@
 /*
  * Copyright 2017-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_COMMON_XML_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 <string.h>
 
 #include <crm/crm.h>  /* transitively imports qblog.h */
 #include <crm/common/output_internal.h>
 #include <crm/common/xml_idref_internal.h>
 #include <crm/common/xml_io_internal.h>
 #include <crm/common/xml_names_internal.h>    // PCMK__XE_PROMOTABLE_LEGACY
 #include <crm/common/xml_names.h>             // PCMK_XA_ID, PCMK_XE_CLONE
 
 #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)
 
 /*
  * \enum pcmk__xml_fmt_options
  * \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);
 
 /* XML search strings for guest, remote and pacemaker_remote nodes */
 
 /* search string to find CIB resources entries for cluster nodes */
 #define PCMK__XP_MEMBER_NODE_CONFIG                                 \
     "//" PCMK_XE_CIB "/" PCMK_XE_CONFIGURATION "/" PCMK_XE_NODES    \
     "/" PCMK_XE_NODE                                                \
     "[not(@" PCMK_XA_TYPE ") or @" PCMK_XA_TYPE "='" PCMK_VALUE_MEMBER "']"
 
 /* search string to find CIB resources entries for guest nodes */
 #define PCMK__XP_GUEST_NODE_CONFIG \
     "//" PCMK_XE_CIB "//" PCMK_XE_CONFIGURATION "//" PCMK_XE_PRIMITIVE  \
     "//" PCMK_XE_META_ATTRIBUTES "//" PCMK_XE_NVPAIR                    \
     "[@" PCMK_XA_NAME "='" PCMK_META_REMOTE_NODE "']"
 
 /* search string to find CIB resources entries for remote nodes */
 #define PCMK__XP_REMOTE_NODE_CONFIG                                     \
     "//" PCMK_XE_CIB "//" PCMK_XE_CONFIGURATION "//" PCMK_XE_PRIMITIVE  \
     "[@" PCMK_XA_TYPE "='" PCMK_VALUE_REMOTE "']"                       \
     "[@" PCMK_XA_PROVIDER "='pacemaker']"
 
 /* search string to find CIB node status entries for pacemaker_remote nodes */
 #define PCMK__XP_REMOTE_NODE_STATUS                                 \
     "//" PCMK_XE_CIB "//" PCMK_XE_STATUS "//" PCMK__XE_NODE_STATE   \
     "[@" PCMK_XA_REMOTE_NODE "='" PCMK_VALUE_TRUE "']"
 /*!
  * \internal
  * \brief Serialize XML (using libxml) into provided descriptor
  *
  * \param[in] fd  File descriptor to (piece-wise) write to
  * \param[in] cur XML subtree to proceed
  * 
  * \return a standard Pacemaker return code
  */
 int pcmk__xml2fd(int fd, xmlNode *cur);
 
 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);
 const char *pcmk__xe_add_last_written(xmlNode *xe);
 
 xmlNode *pcmk__xe_first_child(const xmlNode *parent, const char *node_name,
                               const char *attr_n, const char *attr_v);
 
 
 void pcmk__xe_remove_attr(xmlNode *element, const char *name);
 bool pcmk__xe_remove_attr_cb(xmlNode *xml, void *user_data);
 void pcmk__xe_remove_matching_attrs(xmlNode *element,
                                     bool (*match)(xmlAttrPtr, void *),
                                     void *user_data);
 int pcmk__xe_delete_match(xmlNode *xml, xmlNode *search);
 int pcmk__xe_replace_match(xmlNode *xml, xmlNode *replace);
 int pcmk__xe_update_match(xmlNode *xml, xmlNode *update, uint32_t flags);
 
 GString *pcmk__element_xpath(const xmlNode *xml);
 
 /*!
  * \internal
  * \enum pcmk__xml_escape_type
  * \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 Retrieve the value of the \c PCMK_XA_ID XML attribute
  *
  * \param[in] xml  XML element to check
  *
  * \return Value of the \c PCMK_XA_ID attribute (may be \c NULL)
  */
 static inline const char *
 pcmk__xe_id(const xmlNode *xml)
 {
     return crm_element_value(xml, PCMK_XA_ID);
 }
 
 /*!
  * \internal
  * \brief Check whether an XML element is of a particular type
  *
  * \param[in] xml   XML element to compare
  * \param[in] name  XML element name to compare
  *
  * \return \c true if \p xml is of type \p name, otherwise \c false
  */
 static inline bool
 pcmk__xe_is(const xmlNode *xml, const char *name)
 {
     return (xml != NULL) && (xml->name != NULL) && (name != NULL)
            && (strcmp((const char *) xml->name, name) == 0);
 }
 
 /*!
  * \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;
 }
 
 /*!
  * \internal
  * \brief Return next non-text sibling element of an XML element
  *
  * \param[in] child  XML element to check
  *
  * \return Next sibling element of \p child (or NULL if none)
  */
 static inline xmlNode *
 pcmk__xe_next(const xmlNode *child)
 {
     xmlNode *next = child? child->next : NULL;
 
     while (next && (next->type != XML_ELEMENT_NODE)) {
         next = next->next;
     }
     return next;
 }
 
 xmlNode *pcmk__xe_create(xmlNode *parent, const char *name);
+xmlNode *pcmk__xc_create(xmlDoc *doc, const char *content);
 void pcmk__xml_free(xmlNode *xml);
 void pcmk__xml_free_doc(xmlDoc *doc);
 xmlNode *pcmk__xml_copy(xmlNode *parent, xmlNode *src);
 xmlNode *pcmk__xe_next_same(const xmlNode *node);
 
 void pcmk__xe_set_content(xmlNode *node, const char *format, ...)
     G_GNUC_PRINTF(2, 3);
 
 /*!
  * \internal
  * \enum pcmk__xa_flags
  * \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),
 };
 
 int pcmk__xe_copy_attrs(xmlNode *target, const xmlNode *src, uint32_t flags);
 void pcmk__xe_sort_attrs(xmlNode *xml);
 
 void pcmk__xml_sanitize_id(char *id);
 void pcmk__xe_set_id(xmlNode *xml, const char *format, ...)
     G_GNUC_PRINTF(2, 3);
 
 /*!
  * \internal
  * \brief Like pcmk__xe_set_props, but takes a va_list instead of
  *        arguments directly.
  *
  * \param[in,out] node   XML to add attributes to
  * \param[in]     pairs  NULL-terminated list of name/value pairs to add
  */
 void
 pcmk__xe_set_propv(xmlNodePtr node, va_list pairs);
 
 /*!
  * \internal
  * \brief Add a NULL-terminated list of name/value pairs to the given
  *        XML node as properties.
  *
  * \param[in,out] node XML node to add properties to
  * \param[in]     ...  NULL-terminated list of name/value pairs
  *
  * \note A NULL name terminates the arguments; a NULL value will be skipped.
  */
 void
 pcmk__xe_set_props(xmlNodePtr node, ...)
 G_GNUC_NULL_TERMINATED;
 
 /*!
  * \internal
  * \brief Get first attribute of an XML element
  *
  * \param[in] xe  XML element to check
  *
  * \return First attribute of \p xe (or NULL if \p xe is NULL or has none)
  */
 static inline xmlAttr *
 pcmk__xe_first_attr(const xmlNode *xe)
 {
     return (xe == NULL)? NULL : xe->properties;
 }
 
 /*!
  * \internal
  * \brief Extract the ID attribute from an XML element
  *
  * \param[in] xpath String to search
  * \param[in] node  Node to get the ID for
  *
  * \return ID attribute of \p node in xpath string \p xpath
  */
 char *
 pcmk__xpath_node_id(const char *xpath, const char *node);
 
 /*!
  * \internal
  * \brief Print an informational message if an xpath query returned multiple
  *        items with the same ID.
  *
  * \param[in,out] out       The output object
  * \param[in]     search    The xpath search result, most typically the result of
  *                          calling cib->cmds->query().
  * \param[in]     name      The name searched for
  */
 void
 pcmk__warn_multiple_name_matches(pcmk__output_t *out, xmlNode *search,
                                  const char *name);
 
 /* internal XML-related utilities */
 
 enum xml_private_flags {
      pcmk__xf_none        = 0x0000,
      pcmk__xf_dirty       = 0x0001,
      pcmk__xf_deleted     = 0x0002,
      pcmk__xf_created     = 0x0004,
      pcmk__xf_modified    = 0x0008,
 
      pcmk__xf_tracking    = 0x0010,
      pcmk__xf_processed   = 0x0020,
      pcmk__xf_skip        = 0x0040,
      pcmk__xf_moved       = 0x0080,
 
      pcmk__xf_acl_enabled = 0x0100,
      pcmk__xf_acl_read    = 0x0200,
      pcmk__xf_acl_write   = 0x0400,
      pcmk__xf_acl_deny    = 0x0800,
 
      pcmk__xf_acl_create  = 0x1000,
      pcmk__xf_acl_denied  = 0x2000,
      pcmk__xf_lazy        = 0x4000,
 };
 
 void pcmk__set_xml_doc_flag(xmlNode *xml, enum xml_private_flags flag);
 
 /*!
  * \internal
  * \brief Iterate over child elements of \p xml
  *
  * This function iterates over the children of \p xml, performing the
  * callback function \p handler on each node.  If the callback returns
  * a value other than pcmk_rc_ok, the iteration stops and the value is
  * returned.  It is therefore possible that not all children will be
  * visited.
  *
  * \param[in,out] xml                 The starting XML node.  Can be NULL.
  * \param[in]     child_element_name  The name that the node must match in order
  *                                    for \p handler to be run.  If NULL, all
  *                                    child elements will match.
  * \param[in]     handler             The callback function.
  * \param[in,out] userdata            User data to pass to the callback function.
  *                                    Can be NULL.
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__xe_foreach_child(xmlNode *xml, const char *child_element_name,
                        int (*handler)(xmlNode *xml, void *userdata),
                        void *userdata);
 
 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;
 }
 
 // @COMPAT Drop when PCMK__XE_PROMOTABLE_LEGACY is removed
 static inline const char *
 pcmk__map_element_name(const xmlNode *xml)
 {
     if (xml == NULL) {
         return NULL;
     } else if (pcmk__xe_is(xml, PCMK__XE_PROMOTABLE_LEGACY)) {
         return PCMK_XE_CLONE;
     } else {
         return (const char *) xml->name;
     }
 }
 
 /*!
  * \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/include/crm_internal.h b/include/crm_internal.h
index 247578b4fb..646b14e3f6 100644
--- a/include/crm_internal.h
+++ b/include/crm_internal.h
@@ -1,99 +1,100 @@
 /*
  * Copyright 2006-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__CRM_INTERNAL__H
 #define PCMK__CRM_INTERNAL__H
 
 #ifndef PCMK__CONFIG_H
 #define PCMK__CONFIG_H
 #include <config.h>
 #endif
 
 #include <portability.h>
 
 /* Our minimum glib dependency is 2.42. Define that as both the minimum and
  * maximum glib APIs that are allowed (i.e. APIs that were already deprecated
  * in 2.42, and APIs introduced after 2.42, cannot be used by Pacemaker code).
  */
 #define GLIB_VERSION_MIN_REQUIRED GLIB_VERSION_2_42
 #define GLIB_VERSION_MAX_ALLOWED GLIB_VERSION_2_42
 
 #include <glib.h>
 #include <stdbool.h>
 #include <libxml/tree.h>
 
 /* Public API headers can guard including deprecated API headers with this
  * symbol, thus preventing internal code (which includes this header) from using
  * deprecated APIs, while still allowing external code to use them by default.
  */
 #define PCMK_ALLOW_DEPRECATED 0
 
 #include <crm/lrmd.h>
 #include <crm/cluster/internal.h>
 #include <crm/common/digest_internal.h>
 #include <crm/common/logging.h>
 #include <crm/common/logging_internal.h>
 #include <crm/common/ipc_internal.h>
 #include <crm/common/options_internal.h>
 #include <crm/common/output_internal.h>
 #include <crm/common/scheduler_internal.h>
 #include <crm/common/schemas_internal.h>
 #include <crm/common/servers_internal.h>
+#include <crm/common/xml_comment_internal.h>
 #include <crm/common/xml_internal.h>
 #include <crm/common/xml_io_internal.h>
 #include <crm/common/xml_names_internal.h>
 #include <crm/common/internal.h>
 #include <locale.h>
 #include <gettext.h>
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 #define N_(String) (String)
 
 #ifdef ENABLE_NLS
 #define _(String) gettext(String)
 #else
 #define _(String) (String)
 #endif
 
 
 /*
  * IPC service names that are only used internally
  */
 
 #define PCMK__SERVER_BASED_RO		"cib_ro"
 #define PCMK__SERVER_BASED_RW		"cib_rw"
 #define PCMK__SERVER_BASED_SHM		"cib_shm"
 
 /*
  * IPC commands that can be sent to Pacemaker daemons
  */
 
 #define PCMK__ATTRD_CMD_PEER_REMOVE     "peer-remove"
 #define PCMK__ATTRD_CMD_UPDATE          "update"
 #define PCMK__ATTRD_CMD_UPDATE_BOTH     "update-both"
 #define PCMK__ATTRD_CMD_UPDATE_DELAY    "update-delay"
 #define PCMK__ATTRD_CMD_QUERY           "query"
 #define PCMK__ATTRD_CMD_REFRESH         "refresh"
 #define PCMK__ATTRD_CMD_SYNC_RESPONSE   "sync-response"
 #define PCMK__ATTRD_CMD_CLEAR_FAILURE   "clear-failure"
 #define PCMK__ATTRD_CMD_CONFIRM         "confirm"
 
 #define PCMK__CONTROLD_CMD_NODES        "list-nodes"
 
 #define ST__LEVEL_MIN 1
 #define ST__LEVEL_MAX 9
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // CRM_INTERNAL__H
diff --git a/lib/common/Makefile.am b/lib/common/Makefile.am
index 4f6ffa2b71..b2432bac4d 100644
--- a/lib/common/Makefile.am
+++ b/lib/common/Makefile.am
@@ -1,144 +1,145 @@
 #
 # Copyright 2004-2024 the Pacemaker project contributors
 #
 # The version control history for this file may have further details.
 #
 # This source code is licensed under the GNU General Public License version 2
 # or later (GPLv2+) WITHOUT ANY WARRANTY.
 #
 include $(top_srcdir)/mk/common.mk
 
 AM_CPPFLAGS += -I$(top_builddir)/lib/gnu \
 	       -I$(top_srcdir)/lib/gnu
 
 ## libraries
 lib_LTLIBRARIES	= libcrmcommon.la
 check_LTLIBRARIES = libcrmcommon_test.la
 
 # Disable -Wcast-qual if used, because we do some hacky casting,
 # and because libxml2 has some signatures that should be const but aren't
 # for backward compatibility reasons.
 
 # s390 needs -fPIC 
 # s390-suse-linux/bin/ld: .libs/ipc.o: relocation R_390_PC32DBL against `__stack_chk_fail@@GLIBC_2.4' can not be used when making a shared object; recompile with -fPIC
 
 CFLAGS		= $(CFLAGS_COPY:-Wcast-qual=) -fPIC
 
 # Without "." here, check-recursive will run through the subdirectories first
 # and then run "make check" here.  This will fail, because there's things in
 # the subdirectories that need check_LTLIBRARIES built first.  Adding "." here
 # changes the order so the subdirectories are processed afterwards.
 SUBDIRS = . tests
 
 noinst_HEADERS		= crmcommon_private.h \
 			  mock_private.h
 
 libcrmcommon_la_LDFLAGS	= -version-info 47:0:13
 
 libcrmcommon_la_CFLAGS	= $(CFLAGS_HARDENED_LIB)
 libcrmcommon_la_LDFLAGS	+= $(LDFLAGS_HARDENED_LIB)
 
 libcrmcommon_la_LIBADD = @LIBADD_DL@
 
 # If configured with --with-profiling or --with-coverage, BUILD_PROFILING will
 # be set and -fno-builtin will be added to the CFLAGS.  However, libcrmcommon
 # uses the fabs() function which is normally supplied by gcc as one of its
 # builtins.  Therefore we need to explicitly link against libm here or the
 # tests won't link.
 if BUILD_PROFILING
 libcrmcommon_la_LIBADD	+= -lm
 endif
 
 ## Library sources (*must* use += format for bumplibs)
 libcrmcommon_la_SOURCES	=
 libcrmcommon_la_SOURCES	+= acl.c
 libcrmcommon_la_SOURCES	+= actions.c
 libcrmcommon_la_SOURCES	+= agents.c
 libcrmcommon_la_SOURCES	+= alerts.c
 libcrmcommon_la_SOURCES	+= attrs.c
 libcrmcommon_la_SOURCES	+= cib.c
 if BUILD_CIBSECRETS
 libcrmcommon_la_SOURCES	+= cib_secrets.c
 endif
 libcrmcommon_la_SOURCES	+= cmdline.c
 libcrmcommon_la_SOURCES	+= digest.c
 libcrmcommon_la_SOURCES	+= health.c
 libcrmcommon_la_SOURCES	+= io.c
 libcrmcommon_la_SOURCES	+= ipc_attrd.c
 libcrmcommon_la_SOURCES	+= ipc_client.c
 libcrmcommon_la_SOURCES	+= ipc_common.c
 libcrmcommon_la_SOURCES	+= ipc_controld.c
 libcrmcommon_la_SOURCES	+= ipc_pacemakerd.c
 libcrmcommon_la_SOURCES	+= ipc_schedulerd.c
 libcrmcommon_la_SOURCES	+= ipc_server.c
 libcrmcommon_la_SOURCES	+= iso8601.c
 libcrmcommon_la_SOURCES	+= lists.c
 libcrmcommon_la_SOURCES	+= logging.c
 libcrmcommon_la_SOURCES	+= mainloop.c
 libcrmcommon_la_SOURCES	+= messages.c
 libcrmcommon_la_SOURCES	+= nodes.c
 libcrmcommon_la_SOURCES	+= nvpair.c
 libcrmcommon_la_SOURCES	+= options.c
 libcrmcommon_la_SOURCES	+= options_display.c
 libcrmcommon_la_SOURCES	+= output.c
 libcrmcommon_la_SOURCES	+= output_html.c
 libcrmcommon_la_SOURCES	+= output_log.c
 libcrmcommon_la_SOURCES	+= output_none.c
 libcrmcommon_la_SOURCES	+= output_text.c
 libcrmcommon_la_SOURCES	+= output_xml.c
 libcrmcommon_la_SOURCES	+= patchset.c
 libcrmcommon_la_SOURCES	+= patchset_display.c
 libcrmcommon_la_SOURCES	+= pid.c
 libcrmcommon_la_SOURCES	+= probes.c
 libcrmcommon_la_SOURCES	+= procfs.c
 libcrmcommon_la_SOURCES	+= remote.c
 libcrmcommon_la_SOURCES	+= resources.c
 libcrmcommon_la_SOURCES	+= results.c
 libcrmcommon_la_SOURCES	+= roles.c
 libcrmcommon_la_SOURCES	+= rules.c
 libcrmcommon_la_SOURCES	+= scheduler.c
 libcrmcommon_la_SOURCES	+= schemas.c
 libcrmcommon_la_SOURCES	+= scores.c
 libcrmcommon_la_SOURCES	+= servers.c
 libcrmcommon_la_SOURCES	+= strings.c
 libcrmcommon_la_SOURCES	+= utils.c
 libcrmcommon_la_SOURCES	+= watchdog.c
 libcrmcommon_la_SOURCES	+= xml.c
 libcrmcommon_la_SOURCES	+= xml_attr.c
+libcrmcommon_la_SOURCES	+= xml_comment.c
 libcrmcommon_la_SOURCES	+= xml_display.c
 libcrmcommon_la_SOURCES	+= xml_idref.c
 libcrmcommon_la_SOURCES	+= xml_io.c
 libcrmcommon_la_SOURCES	+= xpath.c
 
 #
 # libcrmcommon_test is used only with unit tests, so we can mock system calls.
 # See mock.c for details.
 #
 
 include $(top_srcdir)/mk/tap.mk
 
 libcrmcommon_test_la_SOURCES	= $(libcrmcommon_la_SOURCES)
 libcrmcommon_test_la_SOURCES	+= mock.c
 libcrmcommon_test_la_SOURCES	+= unittest.c
 libcrmcommon_test_la_LDFLAGS	= $(libcrmcommon_la_LDFLAGS) 	\
 				  -rpath $(libdir) 		\
 				  $(LDFLAGS_WRAP)
 # If GCC emits a builtin function in place of something we've mocked up, that will
 # get used instead of the mocked version which leads to unexpected test results.  So
 # disable all builtins.  Older versions of GCC (at least, on RHEL7) will still emit
 # replacement code for strdup (and possibly other functions) unless -fno-inline is
 # also added.
 libcrmcommon_test_la_CFLAGS	= $(libcrmcommon_la_CFLAGS) 	\
 				  -DPCMK__UNIT_TESTING 		\
 				  -fno-builtin 			\
 				  -fno-inline
 # If -fno-builtin is used, -lm also needs to be added.  See the comment at
 # BUILD_PROFILING above.
 libcrmcommon_test_la_LIBADD	= $(libcrmcommon_la_LIBADD)
 if BUILD_COVERAGE
 libcrmcommon_test_la_LIBADD 	+= -lgcov
 endif
 libcrmcommon_test_la_LIBADD 	+= -lcmocka
 libcrmcommon_test_la_LIBADD 	+= -lm
 
 nodist_libcrmcommon_test_la_SOURCES = $(nodist_libcrmcommon_la_SOURCES)
diff --git a/lib/common/tests/xml/Makefile.am b/lib/common/tests/xml/Makefile.am
index 339a7015c5..546e47e3de 100644
--- a/lib/common/tests/xml/Makefile.am
+++ b/lib/common/tests/xml/Makefile.am
@@ -1,28 +1,29 @@
 #
 # Copyright 2022-2024 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 $(top_srcdir)/mk/tap.mk
 include $(top_srcdir)/mk/unittest.mk
 
 # Add "_test" to the end of all test program names to simplify .gitignore.
-check_PROGRAMS = pcmk__xe_copy_attrs_test	\
+check_PROGRAMS = pcmk__xc_create_test		\
+		 pcmk__xe_copy_attrs_test	\
 		 pcmk__xe_first_child_test	\
 		 pcmk__xe_foreach_child_test 	\
 		 pcmk__xe_set_id_test		\
 		 pcmk__xe_set_score_test	\
 		 pcmk__xe_sort_attrs_test	\
 		 pcmk__xml_escape_test		\
 		 pcmk__xml_init_test		\
 		 pcmk__xml_is_name_char_test	\
 		 pcmk__xml_is_name_start_char_test	\
 		 pcmk__xml_needs_escape_test	\
 		 pcmk__xml_new_doc_test		\
 		 pcmk__xml_sanitize_id_test
 
 TESTS = $(check_PROGRAMS)
diff --git a/lib/common/tests/xml/pcmk__xc_create_test.c b/lib/common/tests/xml/pcmk__xc_create_test.c
new file mode 100644
index 0000000000..4e25adc130
--- /dev/null
+++ b/lib/common/tests/xml/pcmk__xc_create_test.c
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU General Public License version 2
+ * or later (GPLv2+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <crm/common/unittest_internal.h>
+
+#include "crmcommon_private.h"
+
+/* This tests new_private_data() indirectly for comment nodes. Testing
+ * free_private_data() would be much less straightforward and is not worth the
+ * hassle.
+ */
+
+static void
+assert_comment(xmlDoc *doc, const char *content)
+{
+    xmlNode *node = NULL;
+    xml_node_private_t *nodepriv = NULL;
+    xml_doc_private_t *docpriv = doc->_private;
+
+    // Also clears existing doc flags
+    xml_track_changes((xmlNode *) doc, NULL, NULL, false);
+
+    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);
+}
+
+static void
+null_doc(void **state)
+{
+    pcmk__assert_asserts(pcmk__xc_create(NULL, NULL));
+    pcmk__assert_asserts(pcmk__xc_create(NULL, "some content"));
+}
+
+static void
+with_doc(void **state)
+{
+    xmlDoc *doc = pcmk__xml_new_doc();
+
+    assert_non_null(doc);
+    assert_non_null(doc->_private);
+
+    assert_comment(doc, NULL);
+    assert_comment(doc, "some content");
+
+    pcmk__xml_free_doc(doc);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
+                cmocka_unit_test(null_doc),
+                cmocka_unit_test(with_doc));
diff --git a/lib/common/tests/xml/pcmk__xml_init_test.c b/lib/common/tests/xml/pcmk__xml_init_test.c
index 13039720d9..eb0a199cd7 100644
--- a/lib/common/tests/xml/pcmk__xml_init_test.c
+++ b/lib/common/tests/xml/pcmk__xml_init_test.c
@@ -1,190 +1,164 @@
 /*
  * Copyright 2023-2024 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/xml.h>
 #include <crm/common/unittest_internal.h>
 #include <crm/common/xml_internal.h>
 
 #include "crmcommon_private.h"
 
 static void
 buffer_scheme_test(void **state) {
     assert_int_equal(XML_BUFFER_ALLOC_DOUBLEIT, xmlGetBufferAllocationScheme());
 }
 
 /* These functions also serve as unit tests of the static new_private_data
  * function.  We can't test free_private_data because libxml will call that as
  * part of freeing everything else.  By the time we'd get back into a unit test
  * where we could check that private members are NULL, the structure containing
  * the private data would have been freed.
  *
  * This could probably be tested with a lot of function mocking, but that
  * doesn't seem worth it.
  */
 
 static void
 create_element_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xml_node_private_t *priv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNodePtr node = xmlNewDocNode(doc, NULL, (pcmkXmlStr) "test", NULL);
 
     /* Adding a node to the document marks it as dirty */
     docpriv = doc->_private;
     assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
 
     /* Double check things */
     assert_non_null(node);
     assert_int_equal(node->type, XML_ELEMENT_NODE);
 
     /* Check that the private data is initialized correctly */
     priv = node->_private;
     assert_non_null(priv);
     assert_int_equal(priv->check, PCMK__XML_NODE_PRIVATE_MAGIC);
     assert_true(pcmk_all_flags_set(priv->flags, pcmk__xf_dirty|pcmk__xf_created));
 
     /* Clean up */
     pcmk__xml_free_doc(doc);
 }
 
 static void
 create_attr_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xml_node_private_t *priv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNodePtr node = xmlNewDocNode(doc, NULL, (pcmkXmlStr) "test", NULL);
     xmlAttrPtr attr = xmlNewProp(node, (pcmkXmlStr) PCMK_XA_NAME,
                                  (pcmkXmlStr) "dummy-value");
 
     /* Adding a node to the document marks it as dirty */
     docpriv = doc->_private;
     assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
 
     /* Double check things */
     assert_non_null(attr);
     assert_int_equal(attr->type, XML_ATTRIBUTE_NODE);
 
     /* Check that the private data is initialized correctly */
     priv = attr->_private;
     assert_non_null(priv);
     assert_int_equal(priv->check, PCMK__XML_NODE_PRIVATE_MAGIC);
     assert_true(pcmk_all_flags_set(priv->flags, pcmk__xf_dirty|pcmk__xf_created));
 
     /* Clean up */
     pcmk__xml_free_doc(doc);
 }
 
-static void
-create_comment_node(void **state) {
-    xml_doc_private_t *docpriv = NULL;
-    xml_node_private_t *priv = NULL;
-    xmlDoc *doc = pcmk__xml_new_doc();
-    xmlNodePtr node = xmlNewDocComment(doc, (pcmkXmlStr) "blahblah");
-
-    /* Adding a node to the document marks it as dirty */
-    docpriv = doc->_private;
-    assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
-
-    /* Double check things */
-    assert_non_null(node);
-    assert_int_equal(node->type, XML_COMMENT_NODE);
-
-    /* Check that the private data is initialized correctly */
-    priv = node->_private;
-    assert_non_null(priv);
-    assert_int_equal(priv->check, PCMK__XML_NODE_PRIVATE_MAGIC);
-    assert_true(pcmk_all_flags_set(priv->flags, pcmk__xf_dirty|pcmk__xf_created));
-
-    /* Clean up */
-    pcmk__xml_free_doc(doc);
-}
-
 static void
 create_text_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xml_node_private_t *priv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNodePtr node = xmlNewDocText(doc, (pcmkXmlStr) "blahblah");
 
     /* Adding a node to the document marks it as dirty */
     docpriv = doc->_private;
     assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
 
     /* Double check things */
     assert_non_null(node);
     assert_int_equal(node->type, XML_TEXT_NODE);
 
     /* Check that no private data was created */
     priv = node->_private;
     assert_null(priv);
 
     /* Clean up */
     pcmk__xml_free_doc(doc);
 }
 
 static void
 create_dtd_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xml_node_private_t *priv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlDtdPtr dtd = xmlNewDtd(doc, (pcmkXmlStr) PCMK_XA_NAME,
                               (pcmkXmlStr) "externalId",
                               (pcmkXmlStr) "systemId");
 
     /* Adding a node to the document marks it as dirty */
     docpriv = doc->_private;
     assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
 
     /* Double check things */
     assert_non_null(dtd);
     assert_int_equal(dtd->type, XML_DTD_NODE);
 
     /* Check that no private data was created */
     priv = dtd->_private;
     assert_null(priv);
 
     /* Clean up */
     // If you call xmlFreeDtd() before pcmk__xml_free_doc(), you get a segfault
     pcmk__xml_free_doc(doc);
 }
 
 static void
 create_cdata_node(void **state) {
     xml_doc_private_t *docpriv = NULL;
     xml_node_private_t *priv = NULL;
     xmlDoc *doc = pcmk__xml_new_doc();
     xmlNodePtr node = xmlNewCDataBlock(doc, (pcmkXmlStr) "blahblah", 8);
 
     /* Adding a node to the document marks it as dirty */
     docpriv = doc->_private;
     assert_true(pcmk_all_flags_set(docpriv->flags, pcmk__xf_dirty));
 
     /* Double check things */
     assert_non_null(node);
     assert_int_equal(node->type, XML_CDATA_SECTION_NODE);
 
     /* Check that no private data was created */
     priv = node->_private;
     assert_null(priv);
 
     /* Clean up */
     pcmk__xml_free_doc(doc);
 }
 
 // The group setup/teardown functions call pcmk__xml_init()/pcmk__cml_xleanup()
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(buffer_scheme_test),
                 cmocka_unit_test(create_element_node),
                 cmocka_unit_test(create_attr_node),
-                cmocka_unit_test(create_comment_node),
                 cmocka_unit_test(create_text_node),
                 cmocka_unit_test(create_dtd_node),
                 cmocka_unit_test(create_cdata_node));
diff --git a/lib/common/xml_comment.c b/lib/common/xml_comment.c
new file mode 100644
index 0000000000..b0128e0e66
--- /dev/null
+++ b/lib/common/xml_comment.c
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024 the Pacemaker project contributors
+ *
+ * The version control history for this file may have further details.
+ *
+ * This source code is licensed under the GNU Lesser General Public License
+ * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
+ */
+
+#include <crm_internal.h>
+
+#include <stdio.h>                      // NULL
+
+#include <libxml/tree.h>                // xmlDoc, xmlNode, etc.
+
+#include "crmcommon_private.h"
+
+/*!
+ * \internal
+ * \brief Create a new XML comment belonging to a given document
+ *
+ * \param[in] doc      Document that new comment will belong to
+ * \param[in] content  Comment content
+ *
+ * \return Newly created XML comment (guaranteed not to be \c NULL)
+ */
+xmlNode *
+pcmk__xc_create(xmlDoc *doc, const char *content)
+{
+    /* @TODO Allocate comment private data here when we drop
+     * new_private_data()/free_private_data()
+     */
+    xmlNode *node = NULL;
+
+    // Pacemaker typically assumes every xmlNode has a doc
+    CRM_ASSERT(doc != NULL);
+
+    node = xmlNewDocComment(doc, (pcmkXmlStr) content);
+    pcmk__mem_assert(node);
+    pcmk__xml_mark_created(node);
+    return node;
+}
diff --git a/lib/pacemaker/pcmk_acl.c b/lib/pacemaker/pcmk_acl.c
index 5b8cd1797d..76354bf80a 100644
--- a/lib/pacemaker/pcmk_acl.c
+++ b/lib/pacemaker/pcmk_acl.c
@@ -1,398 +1,394 @@
 /*
  * Copyright 2004-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <stdio.h>
 #include <sys/types.h>
 #include <pwd.h>
 #include <string.h>
 #include <stdlib.h>
 #include <stdarg.h>
 
 #include <libxml/parser.h>
 #include <libxml/tree.h>
 #include <libxml/xpath.h>
 #include <libxslt/transform.h>
 #include <libxslt/variables.h>
 #include <libxslt/xsltutils.h>
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 #include <crm/common/internal.h>
 
 #include <pacemaker-internal.h>
 
 #define ACL_NS_PREFIX "http://clusterlabs.org/ns/pacemaker/access/"
 #define ACL_NS_Q_PREFIX  "pcmk-access-"
 #define ACL_NS_Q_WRITABLE (const xmlChar *) ACL_NS_Q_PREFIX   "writable"
 #define ACL_NS_Q_READABLE (const xmlChar *) ACL_NS_Q_PREFIX   "readable"
 #define ACL_NS_Q_DENIED   (const xmlChar *) ACL_NS_Q_PREFIX   "denied"
 
 static const xmlChar *NS_WRITABLE = (const xmlChar *) ACL_NS_PREFIX "writable";
 static const xmlChar *NS_READABLE = (const xmlChar *) ACL_NS_PREFIX "readable";
 static const xmlChar *NS_DENIED =   (const xmlChar *) ACL_NS_PREFIX "denied";
 
 /*!
  * \brief This function takes a node and marks it with the namespace
  *        given in the ns parameter.
  *
  * \param[in,out] i_node
  * \param[in] ns
  * \param[in,out] ret
  * \param[in,out] ns_recycle_writable
  * \param[in,out] ns_recycle_readable
  * \param[in,out] ns_recycle_denied
  */
 static void
 pcmk__acl_mark_node_with_namespace(xmlNode *i_node, const xmlChar *ns, int *ret,
                                    xmlNs **ns_recycle_writable,
                                    xmlNs **ns_recycle_readable,
                                    xmlNs **ns_recycle_denied)
 {
     if (ns == NS_WRITABLE)
     {
         if (*ns_recycle_writable == NULL)
         {
             *ns_recycle_writable = xmlNewNs(xmlDocGetRootElement(i_node->doc),
                                            NS_WRITABLE, ACL_NS_Q_WRITABLE);
         }
         xmlSetNs(i_node, *ns_recycle_writable);
         *ret = pcmk_rc_ok;
     }
     else if (ns == NS_READABLE)
     {
         if (*ns_recycle_readable == NULL)
         {
             *ns_recycle_readable = xmlNewNs(xmlDocGetRootElement(i_node->doc),
                                            NS_READABLE, ACL_NS_Q_READABLE);
         }
         xmlSetNs(i_node, *ns_recycle_readable);
         *ret = pcmk_rc_ok;
     }
     else if (ns == NS_DENIED)
     {
         if (*ns_recycle_denied == NULL)
         {
             *ns_recycle_denied = xmlNewNs(xmlDocGetRootElement(i_node->doc),
                                          NS_DENIED, ACL_NS_Q_DENIED);
         };
         xmlSetNs(i_node, *ns_recycle_denied);
         *ret = pcmk_rc_ok;
     }
 }
 
 /*!
  * \brief Annotate a given XML element or property and its siblings with
  *        XML namespaces to indicate ACL permissions
  *
  * \param[in,out] xml_modify  XML to annotate
  *
  * \return  A standard Pacemaker return code
  *          Namely:
  *          - pcmk_rc_ok upon success,
  *          - pcmk_rc_already if ACLs were not applicable,
  *          - pcmk_rc_schema_validation if the validation schema version
  *              is unsupported (see note), or
  *          - EINVAL or ENOMEM as appropriate;
  *
  * \note This function is recursive
  */
 static int
 annotate_with_siblings(xmlNode *xml_modify)
 {
 
     static xmlNs *ns_recycle_writable = NULL,
                  *ns_recycle_readable = NULL,
                  *ns_recycle_denied = NULL;
     static const xmlDoc *prev_doc = NULL;
 
     xmlNode *i_node = NULL;
     const xmlChar *ns;
     int ret = EINVAL; // nodes have not been processed yet
 
     if (prev_doc == NULL || prev_doc != xml_modify->doc) {
         prev_doc = xml_modify->doc;
         ns_recycle_writable = ns_recycle_readable = ns_recycle_denied = NULL;
     }
 
     for (i_node = xml_modify; i_node != NULL; i_node = i_node->next) {
         switch (i_node->type) {
             case XML_ELEMENT_NODE:
                 pcmk__set_xml_doc_flag(i_node, pcmk__xf_tracking);
 
                 if (!pcmk__check_acl(i_node, NULL, pcmk__xf_acl_read)) {
                     ns = NS_DENIED;
                 } else if (!pcmk__check_acl(i_node, NULL, pcmk__xf_acl_write)) {
                     ns = NS_READABLE;
                 } else {
                     ns = NS_WRITABLE;
                 }
                 pcmk__acl_mark_node_with_namespace(i_node, ns, &ret,
                                                    &ns_recycle_writable,
                                                    &ns_recycle_readable,
                                                    &ns_recycle_denied);
                 // @TODO Could replace recursion with iteration to save stack
                 if (i_node->properties != NULL) {
                     /* This is not entirely clear, but relies on the very same
                      * class-hierarchy emulation that libxml2 has firmly baked
                      * in its API/ABI
                      */
                     ret |= annotate_with_siblings((xmlNodePtr)
                                                   i_node->properties);
                 }
                 if (i_node->children != NULL) {
                     ret |= annotate_with_siblings(i_node->children);
                 }
                 break;
 
             case XML_ATTRIBUTE_NODE:
                 // We can utilize that parent has already been assigned the ns
                 if (!pcmk__check_acl(i_node->parent,
                                      (const char *) i_node->name,
                                      pcmk__xf_acl_read)) {
                     ns = NS_DENIED;
                 } else if (!pcmk__check_acl(i_node,
                                        (const char *) i_node->name,
                                        pcmk__xf_acl_write)) {
                     ns = NS_READABLE;
                 } else {
                     ns = NS_WRITABLE;
                 }
                 pcmk__acl_mark_node_with_namespace(i_node, ns, &ret,
                                                    &ns_recycle_writable,
                                                    &ns_recycle_readable,
                                                    &ns_recycle_denied);
                 break;
 
             case XML_COMMENT_NODE:
                 // We can utilize that parent has already been assigned the ns
                 if (!pcmk__check_acl(i_node->parent,
                                      (const char *) i_node->name,
                                      pcmk__xf_acl_read)) {
                     ns = NS_DENIED;
                 } else if (!pcmk__check_acl(i_node->parent,
                                             (const char *) i_node->name,
                                             pcmk__xf_acl_write)) {
                     ns = NS_READABLE;
                 } else {
                     ns = NS_WRITABLE;
                 }
                 pcmk__acl_mark_node_with_namespace(i_node, ns, &ret,
                                                    &ns_recycle_writable,
                                                    &ns_recycle_readable,
                                                    &ns_recycle_denied);
                 break;
 
             default:
                 break;
         }
     }
 
     return ret;
 }
 
 int
 pcmk__acl_annotate_permissions(const char *cred, const xmlDoc *cib_doc,
                                xmlDoc **acl_evaled_doc)
 {
     int ret;
     xmlNode *target, *comment;
     const char *validation;
 
     CRM_CHECK(cred != NULL, return EINVAL);
     CRM_CHECK(cib_doc != NULL, return EINVAL);
     CRM_CHECK(acl_evaled_doc != NULL, return EINVAL);
 
     /* avoid trivial accidental XML injection */
     if (strpbrk(cred, "<>&") != NULL) {
         return EINVAL;
     }
 
     if (!pcmk_acl_required(cred)) {
         /* nothing to evaluate */
         return pcmk_rc_already;
     }
 
     validation = crm_element_value(xmlDocGetRootElement(cib_doc),
                                    PCMK_XA_VALIDATE_WITH);
 
     if (pcmk__cmp_schemas_by_name(PCMK__COMPAT_ACL_2_MIN_INCL,
                                   validation) > 0) {
         return pcmk_rc_schema_validation;
     }
 
     target = pcmk__xml_copy(NULL, xmlDocGetRootElement((xmlDoc *) cib_doc));
     if (target == NULL) {
         return EINVAL;
     }
 
     pcmk__enable_acl(target, target, cred);
 
     ret = annotate_with_siblings(target);
 
     if (ret == pcmk_rc_ok) {
-        char *credentials = crm_strdup_printf("ACLs as evaluated for user %s",
-                                              cred);
-
-        comment = xmlNewDocComment(target->doc, (pcmkXmlStr) credentials);
-        free(credentials);
-        if (comment == NULL) {
-            pcmk__xml_free(target);
-            return EINVAL;
-        }
+        char *content = crm_strdup_printf("ACLs as evaluated for user %s",
+                                          cred);
+
+        comment = pcmk__xc_create(target->doc, content);
         xmlAddPrevSibling(xmlDocGetRootElement(target->doc), comment);
         *acl_evaled_doc = target->doc;
-        return pcmk_rc_ok;
+        free(content);
+
     } else {
         pcmk__xml_free(target);
-        return ret; //for now, it should be some kind of error
     }
+    return ret;
 }
 
 int
 pcmk__acl_evaled_render(xmlDoc *annotated_doc, enum pcmk__acl_render_how how,
                         xmlChar **doc_txt_ptr)
 {
     xmlDoc *xslt_doc;
     xsltStylesheet *xslt;
     xsltTransformContext *xslt_ctxt;
     xmlDoc *res;
     char *sfile;
     static const char *params_namespace[] = {
         "accessrendercfg:c-writable",           ACL_NS_Q_PREFIX "writable:",
         "accessrendercfg:c-readable",           ACL_NS_Q_PREFIX "readable:",
         "accessrendercfg:c-denied",             ACL_NS_Q_PREFIX "denied:",
         "accessrendercfg:c-reset",              "",
         "accessrender:extra-spacing",           "no",
         "accessrender:self-reproducing-prefix", ACL_NS_Q_PREFIX,
         NULL
     }, *params_useansi[] = {
         /* start with hard-coded defaults, then adapt per the template ones */
         "accessrendercfg:c-writable",           "\x1b[32m",
         "accessrendercfg:c-readable",           "\x1b[34m",
         "accessrendercfg:c-denied",             "\x1b[31m",
         "accessrendercfg:c-reset",              "\x1b[0m",
         "accessrender:extra-spacing",           "no",
         "accessrender:self-reproducing-prefix", ACL_NS_Q_PREFIX,
         NULL
     }, *params_noansi[] = {
         "accessrendercfg:c-writable",           "vvv---[ WRITABLE ]---vvv",
         "accessrendercfg:c-readable",           "vvv---[ READABLE ]---vvv",
         "accessrendercfg:c-denied",             "vvv---[ ~DENIED~ ]---vvv",
         "accessrendercfg:c-reset",              "",
         "accessrender:extra-spacing",           "yes",
         "accessrender:self-reproducing-prefix", "",
         NULL
     };
     const char **params;
     int rc = pcmk_rc_ok;
     xmlParserCtxtPtr parser_ctxt;
 
     /* unfortunately, the input (coming from CIB originally) was parsed with
        blanks ignored, and since the output is a conversion of XML to text
        format (we would be covered otherwise thanks to implicit
        pretty-printing), we need to dump the tree to string output first,
        only to subsequently reparse it -- this time with blanks honoured */
     xmlChar *annotated_dump;
     int dump_size;
 
     CRM_ASSERT(how != pcmk__acl_render_none);
 
     // Color is the default render mode for terminals; text is default otherwise
     if (how == pcmk__acl_render_default) {
         if (isatty(STDOUT_FILENO)) {
             how = pcmk__acl_render_color;
         } else {
             how = pcmk__acl_render_text;
         }
     }
 
     xmlDocDumpFormatMemory(annotated_doc, &annotated_dump, &dump_size, 1);
     res = xmlReadDoc(annotated_dump, "on-the-fly-access-render", NULL,
                      XML_PARSE_NONET);
     CRM_ASSERT(res != NULL);
     xmlFree(annotated_dump);
     pcmk__xml_free_doc(annotated_doc);
     annotated_doc = res;
 
     sfile = pcmk__xml_artefact_path(pcmk__xml_artefact_ns_base_xslt,
                                     "access-render-2");
     parser_ctxt = xmlNewParserCtxt();
 
     CRM_ASSERT(sfile != NULL);
     pcmk__mem_assert(parser_ctxt);
 
     xslt_doc = xmlCtxtReadFile(parser_ctxt, sfile, NULL, XML_PARSE_NONET);
 
     xslt = xsltParseStylesheetDoc(xslt_doc);  /* acquires xslt_doc! */
     if (xslt == NULL) {
         crm_crit("Problem in parsing %s", sfile);
         rc = EINVAL;
         goto done;
     }
     xmlFreeParserCtxt(parser_ctxt);
 
     xslt_ctxt = xsltNewTransformContext(xslt, annotated_doc);
     pcmk__mem_assert(xslt_ctxt);
 
     switch (how) {
         case pcmk__acl_render_namespace:
             params = params_namespace;
             break;
         case pcmk__acl_render_text:
             params = params_noansi;
             break;
         default:
             /* pcmk__acl_render_color is the only remaining option.
              * The compiler complains about params possibly uninitialized if we
              * don't use default here.
              */
             params = params_useansi;
             break;
     }
 
     xsltQuoteUserParams(xslt_ctxt, params);
 
     res = xsltApplyStylesheetUser(xslt, annotated_doc, NULL,
                                   NULL, NULL, xslt_ctxt);
 
     pcmk__xml_free_doc(annotated_doc);
     annotated_doc = NULL;
     xsltFreeTransformContext(xslt_ctxt);
     xslt_ctxt = NULL;
 
     if (how == pcmk__acl_render_color && params != params_useansi) {
         char **param_i = (char **) params;
         do {
             free(*param_i);
         } while (*param_i++ != NULL);
         free(params);
     }
 
     if (res == NULL) {
         rc = EINVAL;
     } else {
         int doc_txt_len;
         int temp = xsltSaveResultToString(doc_txt_ptr, &doc_txt_len, res, xslt);
 
         pcmk__xml_free_doc(res);
         if (temp != 0) {
             rc = EINVAL;
         }
     }
 
 done:
     if (xslt != NULL) {
         xsltFreeStylesheet(xslt);
     }
     free(sfile);
     return rc;
 }