diff --git a/include/crm/common/nvpair_internal.h b/include/crm/common/nvpair_internal.h
index e2d126ec45..4cfc1bcbe2 100644
--- a/include/crm/common/nvpair_internal.h
+++ b/include/crm/common/nvpair_internal.h
@@ -1,69 +1,71 @@
 /*
  * 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.
  */
 
 #ifndef PCMK__CRM_COMMON_NVPAIR_INTERNAL__H
 #define PCMK__CRM_COMMON_NVPAIR_INTERNAL__H
 
 #include <glib.h>                           // gboolean
 #include <libxml/tree.h>                    // xmlNode
 
 #include <crm/common/rules.h>               // pcmk_rule_input_t
 #include <crm/common/iso8601.h>             // crm_time_t
 #include <crm/common/strings_internal.h>    // pcmk__str_eq(), etc.
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 // Data needed to sort XML blocks of name/value pairs
 typedef struct unpack_data_s {
     GHashTable *values;             // Where to put name/value pairs
     const char *first_id;           // Block with this XML ID should sort first
     pcmk_rule_input_t rule_input;   // Data used to evaluate rules
 
     /* Whether each block's values should overwrite any existing ones
      *
      * @COMPAT Only external call paths set this to true. Drop it when we drop
      * pe_eval_nvpairs() and pe_unpack_nvpairs() after replacing with a new
      * public API that doesn't overwrite.
      */
     bool overwrite;
 
     // If not NULL, this will be set to when rule evaluations will change next
     crm_time_t *next_change;
 } pcmk__nvpair_unpack_t;
 
 /*!
  * \internal
  * \brief Insert a meta-attribute into a hash table
  *
  * \param[in] obj    Resource (pcmk__resource_private_t)
  *                   or action (pcmk_action_t) to add to
  * \param[in] name   Meta-attribute name
  * \param[in] value  Value to add
  */
 #define pcmk__insert_meta(obj, name, value) do {                        \
         if (pcmk__str_eq((value), "#default", pcmk__str_casei)) {       \
             /* @COMPAT Deprecated since 2.1.8 */                        \
             pcmk__config_warn("Support for setting meta-attributes "    \
                               "(such as %s) to the explicit value "     \
                               "'#default' is deprecated and will be "   \
                               "removed in a future release", (name));   \
         } else if ((value) != NULL) {                                   \
             pcmk__insert_dup((obj)->meta, (name), (value));             \
         }                                                               \
     } while (0)
 
 int pcmk__xe_get_datetime(const xmlNode *xml, const char *attr, crm_time_t **t);
+int pcmk__xe_get_flags(const xmlNode *xml, const char *name, uint32_t *dest,
+                       uint32_t default_value);
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif // PCMK__CRM_COMMON_NVPAIR_INTERNAL__H
diff --git a/lib/common/nvpair.c b/lib/common/nvpair.c
index 4c8c640226..1637c78e21 100644
--- a/lib/common/nvpair.c
+++ b/lib/common/nvpair.c
@@ -1,975 +1,1028 @@
 /*
  * 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 <stdint.h>         // UINT32_MAX
+#include <inttypes.h>       // PRIu32
 #include <sys/types.h>
 #include <string.h>
 #include <ctype.h>
 #include <glib.h>
 #include <libxml/tree.h>
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/common/xml_internal.h>
 #include "crmcommon_private.h"
 
 /*
  * This file isolates handling of various kinds of name/value pairs:
  *
  * - pcmk_nvpair_t data type
  * - XML attributes (<TAG ... NAME=VALUE ...>)
  * - XML nvpair elements (<nvpair id=ID name=NAME value=VALUE>)
  * - Meta-attributes (for resources and actions)
  */
 
 // pcmk_nvpair_t handling
 
 /*!
  * \internal
  * \brief Allocate a new name/value pair
  *
  * \param[in] name   New name (required)
  * \param[in] value  New value
  *
  * \return Newly allocated name/value pair
  * \note The caller is responsible for freeing the result with
  *       \c pcmk__free_nvpair().
  */
 static pcmk_nvpair_t *
 pcmk__new_nvpair(const char *name, const char *value)
 {
     pcmk_nvpair_t *nvpair = NULL;
 
     pcmk__assert(name);
 
     nvpair = pcmk__assert_alloc(1, sizeof(pcmk_nvpair_t));
 
     nvpair->name = pcmk__str_copy(name);
     nvpair->value = pcmk__str_copy(value);
     return nvpair;
 }
 
 /*!
  * \internal
  * \brief Free a name/value pair
  *
  * \param[in,out] nvpair  Name/value pair to free
  */
 static void
 pcmk__free_nvpair(gpointer data)
 {
     if (data) {
         pcmk_nvpair_t *nvpair = data;
 
         free(nvpair->name);
         free(nvpair->value);
         free(nvpair);
     }
 }
 
 /*!
  * \brief Prepend a name/value pair to a list
  *
  * \param[in,out] nvpairs  List to modify
  * \param[in]     name     New entry's name
  * \param[in]     value    New entry's value
  *
  * \return New head of list
  * \note The caller is responsible for freeing the list with
  *       \c pcmk_free_nvpairs().
  */
 GSList *
 pcmk_prepend_nvpair(GSList *nvpairs, const char *name, const char *value)
 {
     return g_slist_prepend(nvpairs, pcmk__new_nvpair(name, value));
 }
 
 /*!
  * \brief Free a list of name/value pairs
  *
  * \param[in,out] list  List to free
  */
 void
 pcmk_free_nvpairs(GSList *nvpairs)
 {
     g_slist_free_full(nvpairs, pcmk__free_nvpair);
 }
 
 // convenience function for name=value strings
 
 /*!
  * \internal
  * \brief Extract the name and value from an input string formatted as "name=value".
  * If unable to extract them, they are returned as NULL.
  *
  * \param[in]  input The input string, likely from the command line
  * \param[out] name  Everything before the first '=' in the input string
  * \param[out] value Everything after the first '=' in the input string
  *
  * \return 2 if both name and value could be extracted, 1 if only one could, and
  *         and error code otherwise
  */
 int
 pcmk__scan_nvpair(const char *input, char **name, char **value)
 {
 #ifdef HAVE_SSCANF_M
     *name = NULL;
     *value = NULL;
     if (sscanf(input, "%m[^=]=%m[^\n]", name, value) <= 0) {
         return -pcmk_err_bad_nvpair;
     }
 #else
     char *sep = NULL;
     *name = NULL;
     *value = NULL;
 
     sep = strstr(optarg, "=");
     if (sep == NULL) {
         return -pcmk_err_bad_nvpair;
     }
 
     *name = strndup(input, sep-input);
 
     if (*name == NULL) {
         return -ENOMEM;
     }
 
     /* If the last char in optarg is =, the user gave no
      * value for the option.  Leave it as NULL.
      */
     if (*(sep+1) != '\0') {
         *value = strdup(sep+1);
 
         if (*value == NULL) {
             return -ENOMEM;
         }
     }
 #endif
 
     if (*name != NULL && *value != NULL) {
         return 2;
     } else if (*name != NULL || *value != NULL) {
         return 1;
     } else {
         return -pcmk_err_bad_nvpair;
     }
 }
 
 /*!
  * \internal
  * \brief Format a name/value pair.
  *
  * Units can optionally be provided for the value.  Note that unlike most
  * formatting functions, this one returns the formatted string.  It is
  * assumed that the most common use of this function will be to build up
  * a string to be output as part of other functions.
  *
  * \note The caller is responsible for freeing the return value after use.
  *
  * \param[in]     name  The name of the nvpair.
  * \param[in]     value The value of the nvpair.
  * \param[in]     units Optional units for the value, or NULL.
  *
  * \return Newly allocated string with name/value pair
  */
 char *
 pcmk__format_nvpair(const char *name, const char *value, const char *units)
 {
     return crm_strdup_printf("%s=\"%s%s\"", name, value, units ? units : "");
 }
 
 // XML attribute handling
 
 /*!
  * \brief Create an XML attribute with specified name and value
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value on success, \c NULL otherwise
  * \note This does nothing if node, name, or value are \c NULL or empty.
  */
 const char *
 crm_xml_add(xmlNode *node, const char *name, const char *value)
 {
     // @TODO Replace with internal function that returns the new attribute
     bool dirty = FALSE;
     xmlAttr *attr = NULL;
 
     CRM_CHECK(node != NULL, return NULL);
     CRM_CHECK(name != NULL, return NULL);
 
     if (value == NULL) {
         return NULL;
     }
 
     if (pcmk__tracking_xml_changes(node, FALSE)) {
         const char *old = crm_element_value(node, name);
 
         if (old == NULL || value == NULL || strcmp(old, value) != 0) {
             dirty = TRUE;
         }
     }
 
     if (dirty && (pcmk__check_acl(node, name, pcmk__xf_acl_create) == FALSE)) {
         crm_trace("Cannot add %s=%s to %s", name, value, node->name);
         return NULL;
     }
 
     attr = xmlSetProp(node, (pcmkXmlStr) name, (pcmkXmlStr) value);
 
     /* If the attribute already exists, this does nothing. Attribute values
      * don't get private data.
      */
     pcmk__xml_new_private_data((xmlNode *) attr);
 
     if (dirty) {
         pcmk__mark_xml_attr_dirty(attr);
     }
 
     CRM_CHECK(attr && attr->children && attr->children->content, return NULL);
     return (char *)attr->children->content;
 }
 
 /*!
  * \brief Create an XML attribute with specified name and integer value
  *
  * This is like \c crm_xml_add() but taking an integer value.
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if node or name are \c NULL or empty.
  */
 const char *
 crm_xml_add_int(xmlNode *node, const char *name, int value)
 {
     char *number = pcmk__itoa(value);
     const char *added = crm_xml_add(node, name, number);
 
     free(number);
     return added;
 }
 
 /*!
  * \brief Create an XML attribute with specified name and unsigned value
  *
  * This is like \c crm_xml_add() but taking a guint value.
  *
  * \param[in,out] node   XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     ms     Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if node or name are \c NULL or empty.
  */
 const char *
 crm_xml_add_ms(xmlNode *node, const char *name, guint ms)
 {
     char *number = crm_strdup_printf("%u", ms);
     const char *added = crm_xml_add(node, name, number);
 
     free(number);
     return added;
 }
 
 // Maximum size of null-terminated string representation of 64-bit integer
 // -9223372036854775808
 #define LLSTRSIZE 21
 
 /*!
  * \brief Create an XML attribute with specified name and long long int value
  *
  * This is like \c crm_xml_add() but taking a long long int value. It is a
  * useful equivalent for defined types like time_t, etc.
  *
  * \param[in,out] xml    XML node to modify
  * \param[in]     name   Attribute name to set
  * \param[in]     value  Attribute value to set
  *
  * \return New value as string on success, \c NULL otherwise
  * \note This does nothing if xml or name are \c NULL or empty.
  *       This does not support greater than 64-bit values.
  */
 const char *
 crm_xml_add_ll(xmlNode *xml, const char *name, long long value)
 {
     char s[LLSTRSIZE] = { '\0', };
 
     if (snprintf(s, LLSTRSIZE, "%lld", (long long) value) == LLSTRSIZE) {
         return NULL;
     }
     return crm_xml_add(xml, name, s);
 }
 
 /*!
  * \brief Create XML attributes for seconds and microseconds
  *
  * This is like \c crm_xml_add() but taking a struct timeval.
  *
  * \param[in,out] xml        XML node to modify
  * \param[in]     name_sec   Name of XML attribute for seconds
  * \param[in]     name_usec  Name of XML attribute for microseconds (or NULL)
  * \param[in]     value      Time value to set
  *
  * \return New seconds value as string on success, \c NULL otherwise
  * \note This does nothing if xml, name_sec, or value is \c NULL.
  */
 const char *
 crm_xml_add_timeval(xmlNode *xml, const char *name_sec, const char *name_usec,
                     const struct timeval *value)
 {
     const char *added = NULL;
 
     if (xml && name_sec && value) {
         added = crm_xml_add_ll(xml, name_sec, (long long) value->tv_sec);
         if (added && name_usec) {
             // Any error is ignored (we successfully added seconds)
             crm_xml_add_ll(xml, name_usec, (long long) value->tv_usec);
         }
     }
     return added;
 }
 
 /*!
  * \brief Retrieve the value of an XML attribute
  *
  * \param[in] data   XML node to check
  * \param[in] name   Attribute name to check
  *
  * \return Value of specified attribute (may be \c NULL)
  */
 const char *
 crm_element_value(const xmlNode *data, const char *name)
 {
     xmlAttr *attr = NULL;
 
     if (data == NULL) {
         crm_err("Couldn't find %s in NULL", name ? name : "<null>");
         CRM_LOG_ASSERT(data != NULL);
         return NULL;
 
     } else if (name == NULL) {
         crm_err("Couldn't find NULL in %s", data->name);
         return NULL;
     }
 
     attr = xmlHasProp(data, (pcmkXmlStr) name);
     if (!attr || !attr->children) {
         return NULL;
     }
     return (const char *) attr->children->content;
 }
 
 /*!
  * \brief Retrieve the integer value of an XML attribute
  *
  * This is like \c crm_element_value() but getting the value as an integer.
  *
  * \param[in]  data  XML node to check
  * \param[in]  name  Attribute name to check
  * \param[out] dest  Where to store element value
  *
  * \return 0 on success, -1 otherwise
  */
 int
 crm_element_value_int(const xmlNode *data, const char *name, int *dest)
 {
     const char *value = NULL;
 
     CRM_CHECK(dest != NULL, return -1);
     value = crm_element_value(data, name);
     if (value) {
         long long value_ll;
         int rc = pcmk__scan_ll(value, &value_ll, 0LL);
 
         *dest = PCMK__PARSE_INT_DEFAULT;
         if (rc != pcmk_rc_ok) {
             crm_warn("Using default for %s "
                      "because '%s' is not a valid integer: %s",
                      name, value, pcmk_rc_str(rc));
         } else if ((value_ll < INT_MIN) || (value_ll > INT_MAX)) {
             crm_warn("Using default for %s because '%s' is out of range",
                      name, value);
         } else {
             *dest = (int) value_ll;
             return 0;
         }
     }
     return -1;
 }
 
+/*!
+ * \brief Retrieve a flag group from an XML attribute value
+ *
+ * This is like \c crm_element_value() except getting the value as a 32-bit
+ * unsigned integer.
+ *
+ * \param[in]  xml            XML node to check
+ * \param[in]  name           Attribute name to check (must not be NULL)
+ * \param[out] dest           Where to store flags (may be NULL to just
+ *                            validate type)
+ * \param[in]  default_value  What to use for missing or invalid value
+ *
+ * \return Standard Pacemaker return code
+ */
+int
+pcmk__xe_get_flags(const xmlNode *xml, const char *name, uint32_t *dest,
+                   uint32_t default_value)
+{
+    const char *value = NULL;
+    long long value_ll = 0LL;
+    int rc = pcmk_rc_ok;
+
+    if (dest != NULL) {
+        *dest = default_value;
+    }
+
+    if (name == NULL) {
+        return EINVAL;
+    }
+    if (xml == NULL) {
+        return pcmk_rc_ok;
+    }
+    value = crm_element_value(xml, name);
+    if (value == NULL) {
+        return pcmk_rc_ok;
+    }
+
+    rc = pcmk__scan_ll(value, &value_ll, default_value);
+    if ((value_ll < 0) || (value_ll > UINT32_MAX)) {
+        value_ll = default_value;
+        if (rc == pcmk_rc_ok) {
+            rc = pcmk_rc_bad_input;
+        }
+    }
+
+    if (dest != NULL) {
+        *dest = (uint32_t) value_ll;
+    }
+    return rc;
+}
+
 /*!
  * \brief Retrieve the long long integer value of an XML attribute
  *
  * This is like \c crm_element_value() but getting the value as a long long int.
  *
  * \param[in]  data  XML node to check
  * \param[in]  name  Attribute name to check
  * \param[out] dest  Where to store element value
  *
  * \return 0 on success, -1 otherwise
  */
 int
 crm_element_value_ll(const xmlNode *data, const char *name, long long *dest)
 {
     const char *value = NULL;
 
     CRM_CHECK(dest != NULL, return -1);
     value = crm_element_value(data, name);
     if (value != NULL) {
         int rc = pcmk__scan_ll(value, dest, PCMK__PARSE_INT_DEFAULT);
 
         if (rc == pcmk_rc_ok) {
             return 0;
         }
         crm_warn("Using default for %s "
                  "because '%s' is not a valid integer: %s",
                  name, value, pcmk_rc_str(rc));
     }
     return -1;
 }
 
 /*!
  * \brief Retrieve the millisecond value of an XML attribute
  *
  * This is like \c crm_element_value() but returning the value as a guint.
  *
  * \param[in]  data   XML node to check
  * \param[in]  name   Attribute name to check
  * \param[out] dest   Where to store attribute value
  *
  * \return \c pcmk_ok on success, -1 otherwise
  */
 int
 crm_element_value_ms(const xmlNode *data, const char *name, guint *dest)
 {
     const char *value = NULL;
     long long value_ll;
     int rc = pcmk_rc_ok;
 
     CRM_CHECK(dest != NULL, return -1);
     *dest = 0;
     value = crm_element_value(data, name);
     rc = pcmk__scan_ll(value, &value_ll, 0LL);
     if (rc != pcmk_rc_ok) {
         crm_warn("Using default for %s "
                  "because '%s' is not valid milliseconds: %s",
                  name, value, pcmk_rc_str(rc));
         return -1;
     }
     if ((value_ll < 0) || (value_ll > G_MAXUINT)) {
         crm_warn("Using default for %s because '%s' is out of range",
                  name, value);
         return -1;
     }
     *dest = (guint) value_ll;
     return pcmk_ok;
 }
 
 /*!
  * \brief Retrieve the seconds-since-epoch value of an XML attribute
  *
  * This is like \c crm_element_value() but returning the value as a time_t.
  *
  * \param[in]  xml    XML node to check
  * \param[in]  name   Attribute name to check
  * \param[out] dest   Where to store attribute value
  *
  * \return \c pcmk_ok on success, -1 otherwise
  */
 int
 crm_element_value_epoch(const xmlNode *xml, const char *name, time_t *dest)
 {
     long long value_ll = 0;
 
     if (crm_element_value_ll(xml, name, &value_ll) < 0) {
         return -1;
     }
 
     /* Unfortunately, we can't do any bounds checking, since time_t has neither
      * standardized bounds nor constants defined for them.
      */
     *dest = (time_t) value_ll;
     return pcmk_ok;
 }
 
 /*!
  * \brief Retrieve the value of XML second/microsecond attributes as time
  *
  * This is like \c crm_element_value() but returning value as a struct timeval.
  *
  * \param[in]  xml        XML to parse
  * \param[in]  name_sec   Name of XML attribute for seconds
  * \param[in]  name_usec  Name of XML attribute for microseconds
  * \param[out] dest       Where to store result
  *
  * \return \c pcmk_ok on success, -errno on error
  * \note Values default to 0 if XML or XML attribute does not exist
  */
 int
 crm_element_value_timeval(const xmlNode *xml, const char *name_sec,
                           const char *name_usec, struct timeval *dest)
 {
     long long value_i = 0;
 
     CRM_CHECK(dest != NULL, return -EINVAL);
     dest->tv_sec = 0;
     dest->tv_usec = 0;
 
     if (xml == NULL) {
         return pcmk_ok;
     }
 
     /* Unfortunately, we can't do any bounds checking, since there are no
      * constants provided for the bounds of time_t and suseconds_t, and
      * calculating them isn't worth the effort. If there are XML values
      * beyond the native sizes, there will probably be worse problems anyway.
      */
 
     // Parse seconds
     errno = 0;
     if (crm_element_value_ll(xml, name_sec, &value_i) < 0) {
         return -errno;
     }
     dest->tv_sec = (time_t) value_i;
 
     // Parse microseconds
     if (crm_element_value_ll(xml, name_usec, &value_i) < 0) {
         return -errno;
     }
     dest->tv_usec = (suseconds_t) value_i;
 
     return pcmk_ok;
 }
 
 /*!
  * \internal
  * \brief Get a date/time object from an XML attribute value
  *
  * \param[in]  xml   XML with attribute to parse (from CIB)
  * \param[in]  attr  Name of attribute to parse
  * \param[out] t     Where to create date/time object
  *                   (\p *t must be NULL initially)
  *
  * \return Standard Pacemaker return code
  * \note The caller is responsible for freeing \p *t using crm_time_free().
  */
 int
 pcmk__xe_get_datetime(const xmlNode *xml, const char *attr, crm_time_t **t)
 {
     const char *value = NULL;
 
     if ((t == NULL) || (*t != NULL) || (xml == NULL) || (attr == NULL)) {
         return EINVAL;
     }
 
     value = crm_element_value(xml, attr);
     if (value != NULL) {
         *t = crm_time_new(value);
         if (*t == NULL) {
             return pcmk_rc_unpack_error;
         }
     }
     return pcmk_rc_ok;
 }
 
 /*!
  * \brief Retrieve a copy of the value of an XML attribute
  *
  * This is like \c crm_element_value() but allocating new memory for the result.
  *
  * \param[in] data   XML node to check
  * \param[in] name   Attribute name to check
  *
  * \return Value of specified attribute (may be \c NULL)
  * \note The caller is responsible for freeing the result.
  */
 char *
 crm_element_value_copy(const xmlNode *data, const char *name)
 {
     return pcmk__str_copy(crm_element_value(data, name));
 }
 
 /*!
  * \brief Safely add hash table entry to XML as attribute or name-value pair
  *
  * Suitable for \c g_hash_table_foreach(), this function takes a hash table key
  * and value, with an XML node passed as user data, and adds an XML attribute
  * with the specified name and value if it does not already exist. If the key
  * name starts with a digit, then it's not a valid XML attribute name. In that
  * case, this will instead add a <tt><param name=NAME value=VALUE/></tt> child
  * to the XML.
  *
  * \param[in]     key        Key of hash table entry
  * \param[in]     value      Value of hash table entry
  * \param[in,out] user_data  XML node
  */
 void
 hash2smartfield(gpointer key, gpointer value, gpointer user_data)
 {
     /* @TODO Generate PCMK__XE_PARAM nodes for all keys that aren't valid XML
      * attribute names (not just those that start with digits), or possibly for
      * all keys to simplify parsing.
      *
      * Consider either deprecating as public API or exposing PCMK__XE_PARAM.
      * PCMK__XE_PARAM is currently private because it doesn't appear in any
      * output that Pacemaker generates.
      */
     const char *name = key;
     const char *s_value = value;
 
     xmlNode *xml_node = user_data;
 
     if (isdigit(name[0])) {
         xmlNode *tmp = pcmk__xe_create(xml_node, PCMK__XE_PARAM);
 
         crm_xml_add(tmp, PCMK_XA_NAME, name);
         crm_xml_add(tmp, PCMK_XA_VALUE, s_value);
 
     } else if (crm_element_value(xml_node, name) == NULL) {
         crm_xml_add(xml_node, name, s_value);
         crm_trace("dumped: %s=%s", name, s_value);
 
     } else {
         crm_trace("duplicate: %s=%s", name, s_value);
     }
 }
 
 /*!
  * \brief Set XML attribute based on hash table entry
  *
  * Suitable for \c g_hash_table_foreach(), this function takes a hash table key
  * and value, with an XML node passed as user data, and adds an XML attribute
  * with the specified name and value if it does not already exist.
  *
  * \param[in]     key        Key of hash table entry
  * \param[in]     value      Value of hash table entry
  * \param[in,out] user_data  XML node
  */
 void
 hash2field(gpointer key, gpointer value, gpointer user_data)
 {
     const char *name = key;
     const char *s_value = value;
 
     xmlNode *xml_node = user_data;
 
     if (crm_element_value(xml_node, name) == NULL) {
         crm_xml_add(xml_node, name, s_value);
 
     } else {
         crm_trace("duplicate: %s=%s", name, s_value);
     }
 }
 
 /*!
  * \brief Set XML attribute based on hash table entry, as meta-attribute name
  *
  * Suitable for \c g_hash_table_foreach(), this function takes a hash table key
  * and value, with an XML node passed as user data, and adds an XML attribute
  * with the meta-attribute version of the specified name and value if it does
  * not already exist and if the name does not appear to be cluster-internal.
  *
  * \param[in]     key        Key of hash table entry
  * \param[in]     value      Value of hash table entry
  * \param[in,out] user_data  XML node
  */
 void
 hash2metafield(gpointer key, gpointer value, gpointer user_data)
 {
     char *crm_name = NULL;
 
     if (key == NULL || value == NULL) {
         return;
     }
 
     /* Filter out cluster-generated attributes that contain a '#' or ':'
      * (like fail-count and last-failure).
      */
     for (crm_name = key; *crm_name; ++crm_name) {
         if ((*crm_name == '#') || (*crm_name == ':')) {
             return;
         }
     }
 
     crm_name = crm_meta_name(key);
     hash2field(crm_name, value, user_data);
     free(crm_name);
 }
 
 // nvpair handling
 
 /*!
  * \brief Create an XML name/value pair
  *
  * \param[in,out] parent  If not \c NULL, make new XML node a child of this one
  * \param[in]     id      Set this as XML ID (or NULL to auto-generate)
  * \param[in]     name    Name to use
  * \param[in]     value   Value to use
  *
  * \return New XML object on success, \c NULL otherwise
  */
 xmlNode *
 crm_create_nvpair_xml(xmlNode *parent, const char *id, const char *name,
                       const char *value)
 {
     xmlNode *nvp;
 
     /* id can be NULL so we auto-generate one, and name can be NULL if this
      * will be used to delete a name/value pair by ID, but both can't be NULL
      */
     CRM_CHECK(id || name, return NULL);
 
     nvp = pcmk__xe_create(parent, PCMK_XE_NVPAIR);
 
     if (id) {
         crm_xml_add(nvp, PCMK_XA_ID, id);
     } else {
         pcmk__xe_set_id(nvp, "%s-%s",
                         pcmk__s(pcmk__xe_id(parent), PCMK_XE_NVPAIR), name);
     }
     crm_xml_add(nvp, PCMK_XA_NAME, name);
     crm_xml_add(nvp, PCMK_XA_VALUE, value);
     return nvp;
 }
 
 /*!
  * \brief Retrieve XML attributes as a hash table
  *
  * Given an XML element, this will look for any \<attributes> element child,
  * creating a hash table of (newly allocated string) name/value pairs taken
  * first from the attributes element's NAME=VALUE XML attributes, and then
  * from any \<param name=NAME value=VALUE> children of attributes.
  *
  * \param[in]  XML node to parse
  *
  * \return Hash table with name/value pairs
  * \note It is the caller's responsibility to free the result using
  *       \c g_hash_table_destroy().
  */
 GHashTable *
 xml2list(const xmlNode *parent)
 {
     xmlNode *child = NULL;
     xmlAttrPtr pIter = NULL;
     xmlNode *nvpair_list = NULL;
     GHashTable *nvpair_hash = pcmk__strkey_table(free, free);
 
     CRM_CHECK(parent != NULL, return nvpair_hash);
 
     nvpair_list = pcmk__xe_first_child(parent, PCMK__XE_ATTRIBUTES, NULL, NULL);
     if (nvpair_list == NULL) {
         crm_trace("No attributes in %s", parent->name);
         crm_log_xml_trace(parent, "No attributes for resource op");
     }
 
     crm_log_xml_trace(nvpair_list, "Unpacking");
 
     for (pIter = pcmk__xe_first_attr(nvpair_list); pIter != NULL;
          pIter = pIter->next) {
 
         const char *p_name = (const char *)pIter->name;
         const char *p_value = pcmk__xml_attr_value(pIter);
 
         crm_trace("Added %s=%s", p_name, p_value);
 
         pcmk__insert_dup(nvpair_hash, p_name, p_value);
     }
 
     for (child = pcmk__xe_first_child(nvpair_list, PCMK__XE_PARAM, NULL, NULL);
          child != NULL; child = pcmk__xe_next_same(child)) {
 
         const char *key = crm_element_value(child, PCMK_XA_NAME);
         const char *value = crm_element_value(child, PCMK_XA_VALUE);
 
         crm_trace("Added %s=%s", key, value);
         if (key != NULL && value != NULL) {
             pcmk__insert_dup(nvpair_hash, key, value);
         }
     }
 
     return nvpair_hash;
 }
 
 void
 pcmk__xe_set_bool_attr(xmlNodePtr node, const char *name, bool value)
 {
     crm_xml_add(node, name, pcmk__btoa(value));
 }
 
 int
 pcmk__xe_get_bool_attr(const xmlNode *node, const char *name, bool *value)
 {
     const char *xml_value = NULL;
     int ret, rc;
 
     if (node == NULL) {
         return ENODATA;
     } else if (name == NULL || value == NULL) {
         return EINVAL;
     }
 
     xml_value = crm_element_value(node, name);
 
     if (xml_value == NULL) {
         return ENODATA;
     }
 
     rc = crm_str_to_boolean(xml_value, &ret);
     if (rc == 1) {
         *value = ret;
         return pcmk_rc_ok;
     } else {
         return pcmk_rc_bad_input;
     }
 }
 
 bool
 pcmk__xe_attr_is_true(const xmlNode *node, const char *name)
 {
     bool value = false;
     int rc;
 
     rc = pcmk__xe_get_bool_attr(node, name, &value);
     return rc == pcmk_rc_ok && value == true;
 }
 
 // Meta-attribute handling
 
 /*!
  * \brief Get the environment variable equivalent of a meta-attribute name
  *
  * \param[in] attr_name  Name of meta-attribute
  *
  * \return Newly allocated string for \p attr_name with "CRM_meta_" prefix and
  *         underbars instead of dashes
  * \note This asserts on an invalid argument or memory allocation error, so
  *       callers can assume the result is non-NULL. The caller is responsible
  *       for freeing the result using free().
  */
 char *
 crm_meta_name(const char *attr_name)
 {
     char *env_name = NULL;
 
     pcmk__assert(!pcmk__str_empty(attr_name));
 
     env_name = crm_strdup_printf(CRM_META "_%s", attr_name);
     for (char *c = env_name; *c != '\0'; ++c) {
         if (*c == '-') {
             *c = '_';
         }
     }
     return env_name;
 }
 
 /*!
  * \brief Get the value of a meta-attribute
  *
  * Get the value of a meta-attribute from a hash table whose keys are
  * meta-attribute environment variable names (as crm_meta_name() would
  * create, like pcmk__graph_action_t:params, not pcmk_resource_t:meta).
  *
  * \param[in] meta       Hash table of meta-attributes
  * \param[in] attr_name  Name of meta-attribute to get
  *
  * \return Value of given meta-attribute
  */
 const char *
 crm_meta_value(GHashTable *meta, const char *attr_name)
 {
     if ((meta != NULL) && (attr_name != NULL)) {
         char *key = crm_meta_name(attr_name);
         const char *value = g_hash_table_lookup(meta, key);
 
         free(key);
         return value;
     }
     return NULL;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <crm/common/nvpair_compat.h>
 
 static gint
 pcmk__compare_nvpair(gconstpointer a, gconstpointer b)
 {
     int rc = 0;
     const pcmk_nvpair_t *pair_a = a;
     const pcmk_nvpair_t *pair_b = b;
 
     pcmk__assert((pair_a != NULL) && (pair_a->name != NULL)
                  && (pair_b != NULL) && (pair_b->name != NULL));
 
     rc = strcmp(pair_a->name, pair_b->name);
     if (rc < 0) {
         return -1;
     } else if (rc > 0) {
         return 1;
     }
     return 0;
 }
 
 GSList *
 pcmk_sort_nvpairs(GSList *list)
 {
     return g_slist_sort(list, pcmk__compare_nvpair);
 }
 
 GSList *
 pcmk_xml_attrs2nvpairs(const xmlNode *xml)
 {
     GSList *result = NULL;
 
     for (xmlAttrPtr iter = pcmk__xe_first_attr(xml); iter != NULL;
          iter = iter->next) {
 
         result = pcmk_prepend_nvpair(result,
                                      (const char *) iter->name,
                                      (const char *) pcmk__xml_attr_value(iter));
     }
     return result;
 }
 
 static void
 pcmk__nvpair_add_xml_attr(gpointer data, gpointer user_data)
 {
     pcmk_nvpair_t *pair = data;
     xmlNode *parent = user_data;
 
     crm_xml_add(parent, pair->name, pair->value);
 }
 
 void
 pcmk_nvpairs2xml_attrs(GSList *list, xmlNode *xml)
 {
     g_slist_foreach(list, pcmk__nvpair_add_xml_attr, xml);
 }
 
 void
 hash2nvpair(gpointer key, gpointer value, gpointer user_data)
 {
     const char *name = key;
     const char *s_value = value;
     xmlNode *xml_node = user_data;
 
     crm_create_nvpair_xml(xml_node, name, name, s_value);
     crm_trace("dumped: name=%s value=%s", name, s_value);
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API
diff --git a/lib/common/tests/nvpair/Makefile.am b/lib/common/tests/nvpair/Makefile.am
index 9f762d4853..59d3788242 100644
--- a/lib/common/tests/nvpair/Makefile.am
+++ b/lib/common/tests/nvpair/Makefile.am
@@ -1,21 +1,22 @@
 #
 # Copyright 2021-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 = crm_meta_name_test		\
 		 crm_meta_value_test		\
 		 pcmk__xe_attr_is_true_test 	\
-		 pcmk__xe_get_datetime_test	\
 		 pcmk__xe_get_bool_attr_test 	\
+		 pcmk__xe_get_datetime_test	\
+		 pcmk__xe_get_flags_test	\
 		 pcmk__xe_set_bool_attr_test
 
 TESTS = $(check_PROGRAMS)
diff --git a/lib/common/tests/nvpair/pcmk__xe_get_flags_test.c b/lib/common/tests/nvpair/pcmk__xe_get_flags_test.c
new file mode 100644
index 0000000000..3462405ba5
--- /dev/null
+++ b/lib/common/tests/nvpair/pcmk__xe_get_flags_test.c
@@ -0,0 +1,117 @@
+/*
+ * 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 <stdint.h>         // UINT32_MAX
+#include <libxml/tree.h>
+
+#include <crm/common/unittest_internal.h>
+
+#include <crm/common/iso8601.h>
+#include <crm/common/xml.h>
+#include <crm/common/nvpair_internal.h>
+
+#define DEFAULT_VALUE 0xfff
+
+static void
+assert_flags(const char *value, int expected_rc, unsigned int expected_flags)
+{
+    int rc = pcmk_rc_ok;
+    uint32_t flags = 0U;
+    xmlNode *xml = pcmk__xe_create(NULL, "element");
+
+    assert_non_null(xml);
+    crm_xml_add(xml, "attribute", value);
+
+    // Without output argument
+    assert_int_equal(pcmk__xe_get_flags(xml, "attribute", NULL, DEFAULT_VALUE),
+                     expected_rc);
+
+    // With output argument
+    rc = pcmk__xe_get_flags(xml, "attribute", &flags, DEFAULT_VALUE);
+    assert_int_equal(rc, expected_rc);
+    assert_true(flags == expected_flags);
+
+    pcmk__xml_free(xml);
+}
+
+static void
+null_name_invalid(void **state)
+{
+    int rc = pcmk_rc_ok;
+    uint32_t flags = 0U;
+    xmlNode *xml = pcmk__xe_create(NULL, "element");
+
+    assert_non_null(xml);
+
+    assert_int_equal(pcmk__xe_get_flags(NULL, NULL, NULL, DEFAULT_VALUE),
+                     EINVAL);
+
+    assert_int_equal(pcmk__xe_get_flags(xml, NULL, NULL, DEFAULT_VALUE),
+                     EINVAL);
+
+    rc = pcmk__xe_get_flags(xml, NULL, &flags, DEFAULT_VALUE);
+    assert_int_equal(rc, EINVAL);
+    assert_true(flags == DEFAULT_VALUE);
+
+    flags = 0U;
+    rc = pcmk__xe_get_flags(NULL, NULL, &flags, DEFAULT_VALUE);
+    assert_int_equal(rc, EINVAL);
+    assert_true(flags == DEFAULT_VALUE);
+
+    pcmk__xml_free(xml);
+}
+
+static void
+null_xml_default(void **state)
+{
+    int rc = pcmk_rc_ok;
+    uint32_t flags = 0U;
+
+    assert_int_equal(pcmk__xe_get_flags(NULL, "attribute", NULL, DEFAULT_VALUE),
+                     pcmk_rc_ok);
+
+    rc = pcmk__xe_get_flags(NULL, "attribute", &flags, DEFAULT_VALUE);
+    assert_int_equal(rc, pcmk_rc_ok);
+    assert_true(flags == DEFAULT_VALUE);
+}
+
+static void
+no_attr_default(void **state)
+{
+    assert_flags(NULL, pcmk_rc_ok, DEFAULT_VALUE);
+}
+
+static void
+invalid_attr_default(void **state)
+{
+    char *too_big = crm_strdup_printf("%lld", UINT32_MAX + 1LL);
+
+    assert_flags("x", pcmk_rc_bad_input, DEFAULT_VALUE);
+    assert_flags("-1", pcmk_rc_bad_input, DEFAULT_VALUE);
+    assert_flags(too_big, pcmk_rc_bad_input, DEFAULT_VALUE);
+    free(too_big);
+}
+
+static void
+valid_attr(void **state)
+{
+    assert_flags("0", pcmk_rc_ok, 0x0);
+    assert_flags("15", pcmk_rc_ok, 0x0f);
+    assert_flags("61462", pcmk_rc_ok, 0xf016);
+    assert_flags("4294967295", pcmk_rc_ok, 0xffffffff);
+}
+
+PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
+                cmocka_unit_test(null_name_invalid),
+                cmocka_unit_test(null_xml_default),
+                cmocka_unit_test(no_attr_default),
+                cmocka_unit_test(invalid_attr_default),
+                cmocka_unit_test(valid_attr))