diff --git a/include/crm/common/rules_internal.h b/include/crm/common/rules_internal.h
index 3d58128d82..37695188ad 100644
--- a/include/crm/common/rules_internal.h
+++ b/include/crm/common/rules_internal.h
@@ -1,52 +1,54 @@
 /*
  * 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_RULES_INTERNAL__H
 #define PCMK__CRM_COMMON_RULES_INTERNAL__H
 
 #include <regex.h>                      // regmatch_t
 #include <libxml/tree.h>                // xmlNode
 
 #include <crm/common/rules.h>           // enum expression_type
 #include <crm/common/iso8601.h>         // crm_time_t
 
 // How node attribute values may be compared in rules
 enum pcmk__comparison {
     pcmk__comparison_unknown,
     pcmk__comparison_defined,
     pcmk__comparison_undefined,
     pcmk__comparison_eq,
     pcmk__comparison_ne,
     pcmk__comparison_lt,
     pcmk__comparison_lte,
     pcmk__comparison_gt,
     pcmk__comparison_gte,
 };
 
 // How node attribute values may be parsed in rules
 enum pcmk__type {
     pcmk__type_unknown,
     pcmk__type_string,
     pcmk__type_integer,
     pcmk__type_number,
     pcmk__type_version,
 };
 
 enum expression_type pcmk__expression_type(const xmlNode *expr);
 enum pcmk__comparison pcmk__parse_comparison(const char *op);
 enum pcmk__type pcmk__parse_type(const char *type, enum pcmk__comparison op,
                                  const char *value1, const char *value2);
+int pcmk__cmp_by_type(const char *l_val, const char *r_val,
+                      enum pcmk__type type);
 char *pcmk__replace_submatches(const char *string, const char *match,
                                const regmatch_t submatches[], int nmatches);
 
 int pcmk__evaluate_date_expression(const xmlNode *date_expression,
                                    const crm_time_t *now,
                                    crm_time_t *next_change);
 
 #endif // PCMK__CRM_COMMON_RULES_INTERNAL__H
diff --git a/lib/common/rules.c b/lib/common/rules.c
index ef573e0520..bb58edb980 100644
--- a/lib/common/rules.c
+++ b/lib/common/rules.c
@@ -1,800 +1,894 @@
 /*
  * 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>                          // NULL, size_t
 #include <stdlib.h>                         // calloc()
 #include <stdbool.h>                        // bool
 #include <ctype.h>                          // isdigit()
 #include <regex.h>                          // regmatch_t
 #include <stdint.h>                         // uint32_t
 #include <inttypes.h>                       // PRIu32
 #include <glib.h>                           // gboolean, FALSE
 #include <libxml/tree.h>                    // xmlNode
 
 #include <crm/common/scheduler.h>
 
 #include <crm/common/iso8601_internal.h>
 #include <crm/common/nvpair_internal.h>
 #include <crm/common/scheduler_internal.h>
 #include "crmcommon_private.h"
 
 /*!
  * \internal
  * \brief Get the expression type corresponding to given expression XML
  *
  * \param[in] expr  Rule expression XML
  *
  * \return Expression type corresponding to \p expr
  */
 enum expression_type
 pcmk__expression_type(const xmlNode *expr)
 {
     const char *name = NULL;
 
     // Expression types based on element name
 
     if (pcmk__xe_is(expr, PCMK_XE_DATE_EXPRESSION)) {
         return pcmk__subexpr_datetime;
 
     } else if (pcmk__xe_is(expr, PCMK_XE_RSC_EXPRESSION)) {
         return pcmk__subexpr_resource;
 
     } else if (pcmk__xe_is(expr, PCMK_XE_OP_EXPRESSION)) {
         return pcmk__subexpr_operation;
 
     } else if (pcmk__xe_is(expr, PCMK_XE_RULE)) {
         return pcmk__subexpr_rule;
 
     } else if (!pcmk__xe_is(expr, PCMK_XE_EXPRESSION)) {
         return pcmk__subexpr_unknown;
     }
 
     // Expression types based on node attribute name
 
     name = crm_element_value(expr, PCMK_XA_ATTRIBUTE);
 
     if (pcmk__str_any_of(name, CRM_ATTR_UNAME, CRM_ATTR_KIND, CRM_ATTR_ID,
                          NULL)) {
         return pcmk__subexpr_location;
     }
 
     return pcmk__subexpr_attribute;
 }
 
 /*!
  * \internal
  * \brief Get parent XML element's ID for logging purposes
  *
  * \param[in] xml  XML of a subelement
  *
  * \return ID of \p xml's parent for logging purposes (guaranteed non-NULL)
  */
 static const char *
 loggable_parent_id(const xmlNode *xml)
 {
     // Default if called without parent (likely for unit testing)
     const char *parent_id = "implied";
 
     if ((xml != NULL) && (xml->parent != NULL)) {
         parent_id = pcmk__xe_id(xml->parent);
         if (parent_id == NULL) { // Not possible with schema validation enabled
             parent_id = "without ID";
         }
     }
     return parent_id;
 }
 
 /*!
  * \internal
  * \brief Get the moon phase corresponding to a given date/time
  *
  * \param[in] now  Date/time to get moon phase for
  *
  * \return Phase of the moon corresponding to \p now, where 0 is the new moon
  *         and 7 is the full moon
  * \deprecated This feature has been deprecated since 2.1.6.
  */
 static int
 phase_of_the_moon(const crm_time_t *now)
 {
     /* As per the nethack rules:
      * - A moon period is 29.53058 days ~= 30
      * - A year is 365.2422 days
      * - Number of days moon phase advances on first day of year compared to
      *   preceding year is (365.2422 - 12 * 29.53058) ~= 11
      * - Number of years until same phases fall on the same days of the month
      *   is 18.6 ~= 19
      * - Moon phase on first day of year (epact) ~= (11 * (year%19) + 29) % 30
      *   (29 as initial condition)
      * - Current phase in days = first day phase + days elapsed in year
      * - 6 moons ~= 177 days ~= 8 reported phases * 22 (+ 11/22 for rounding)
      */
     uint32_t epact, diy, goldn;
     uint32_t y;
 
     crm_time_get_ordinal(now, &y, &diy);
     goldn = (y % 19) + 1;
     epact = (11 * goldn + 18) % 30;
     if (((epact == 25) && (goldn > 11)) || (epact == 24)) {
         epact++;
     }
     return (((((diy + epact) * 6) + 11) % 177) / 22) & 7;
 }
 
 /*!
  * \internal
  * \brief Check an integer value against a range from a date specification
  *
  * \param[in] date_spec  XML of PCMK_XE_DATE_SPEC element to check
  * \param[in] id         XML ID for logging purposes
  * \param[in] attr       Name of XML attribute with range to check against
  * \param[in] value      Value to compare against range
  *
  * \return Standard Pacemaker return code (specifically, pcmk_rc_before_range,
  *         pcmk_rc_after_range, or pcmk_rc_ok to indicate that result is either
  *         within range or undetermined)
  * \note We return pcmk_rc_ok for an undetermined result so we can continue
  *       checking the next range attribute.
  */
 static int
 check_range(const xmlNode *date_spec, const char *id, const char *attr,
             uint32_t value)
 {
     int rc = pcmk_rc_ok;
     const char *range = crm_element_value(date_spec, attr);
     long long low, high;
 
     if (range == NULL) { // Attribute not present
         goto bail;
     }
 
     if (pcmk__parse_ll_range(range, &low, &high) != pcmk_rc_ok) {
         // Invalid range
         /* @COMPAT When we can break behavioral backward compatibility, treat
          * the entire rule as not passing.
          */
         pcmk__config_err("Ignoring " PCMK_XE_DATE_SPEC
                          " %s attribute %s because '%s' is not a valid range",
                          id, attr, range);
 
     } else if ((low != -1) && (value < low)) {
         rc = pcmk_rc_before_range;
 
     } else if ((high != -1) && (value > high)) {
         rc = pcmk_rc_after_range;
     }
 
 bail:
     crm_trace("Checked " PCMK_XE_DATE_SPEC " %s %s='%s' for %" PRIu32 ": %s",
               id, attr, pcmk__s(range, ""), value, pcmk_rc_str(rc));
     return rc;
 }
 
 /*!
  * \internal
  * \brief Evaluate a date specification for a given date/time
  *
  * \param[in] date_spec  XML of PCMK_XE_DATE_SPEC element to evaluate
  * \param[in] now        Time to check
  *
  * \return Standard Pacemaker return code (specifically, EINVAL for NULL
  *         arguments, pcmk_rc_ok if time matches specification, or
  *         pcmk_rc_before_range, pcmk_rc_after_range, or pcmk_rc_op_unsatisfied
  *         as appropriate to how time relates to specification)
  */
 int
 pcmk__evaluate_date_spec(const xmlNode *date_spec, const crm_time_t *now)
 {
     const char *id = NULL;
     const char *parent_id = loggable_parent_id(date_spec);
 
     // Range attributes that can be specified for a PCMK_XE_DATE_SPEC element
     struct range {
         const char *attr;
         uint32_t value;
     } ranges[] = {
         { PCMK_XA_YEARS, 0U },
         { PCMK_XA_MONTHS, 0U },
         { PCMK_XA_MONTHDAYS, 0U },
         { PCMK_XA_HOURS, 0U },
         { PCMK_XA_MINUTES, 0U },
         { PCMK_XA_SECONDS, 0U },
         { PCMK_XA_YEARDAYS, 0U },
         { PCMK_XA_WEEKYEARS, 0U },
         { PCMK_XA_WEEKS, 0U },
         { PCMK_XA_WEEKDAYS, 0U },
         { PCMK__XA_MOON, 0U },
     };
 
     if ((date_spec == NULL) || (now == NULL)) {
         return EINVAL;
     }
 
     // Get specification ID (for logging)
     id = pcmk__xe_id(date_spec);
     if (pcmk__str_empty(id)) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * fail the specification
          */
         pcmk__config_warn(PCMK_XE_DATE_SPEC " subelement of "
                           PCMK_XE_DATE_EXPRESSION " %s has no " PCMK_XA_ID,
                           parent_id);
         id = "without ID"; // for logging
     }
 
     // Year, month, day
     crm_time_get_gregorian(now, &(ranges[0].value), &(ranges[1].value),
                            &(ranges[2].value));
 
     // Hour, minute, second
     crm_time_get_timeofday(now, &(ranges[3].value), &(ranges[4].value),
                            &(ranges[5].value));
 
     // Year (redundant) and day of year
     crm_time_get_ordinal(now, &(ranges[0].value), &(ranges[6].value));
 
     // Week year, week of week year, day of week
     crm_time_get_isoweek(now, &(ranges[7].value), &(ranges[8].value),
                          &(ranges[9].value));
 
     // Moon phase (deprecated)
     ranges[10].value = phase_of_the_moon(now);
     if (crm_element_value(date_spec, PCMK__XA_MOON) != NULL) {
         pcmk__config_warn("Support for '" PCMK__XA_MOON "' in "
                           PCMK_XE_DATE_SPEC " elements (such as %s) is "
                           "deprecated and will be removed in a future release "
                           "of Pacemaker", id);
     }
 
     for (int i = 0; i < PCMK__NELEM(ranges); ++i) {
         int rc = check_range(date_spec, id, ranges[i].attr, ranges[i].value);
 
         if (rc != pcmk_rc_ok) {
             return rc;
         }
     }
 
     // All specified ranges passed, or none were given (also considered a pass)
     return pcmk_rc_ok;
 }
 
 #define ADD_COMPONENT(component) do {                                       \
         int sub_rc = pcmk__add_time_from_xml(*end, component, duration);    \
         if (sub_rc != pcmk_rc_ok) {                                         \
             /* @COMPAT return sub_rc when we can break compatibility */     \
             pcmk__config_warn("Ignoring %s in " PCMK_XE_DURATION " %s "     \
                               "because it is invalid",                      \
                               pcmk__time_component_attr(component), id);    \
             rc = sub_rc;                                                    \
         }                                                                   \
     } while (0)
 
 /*!
  * \internal
  * \brief Given a duration and a start time, calculate the end time
  *
  * \param[in]  duration  XML of PCMK_XE_DURATION element
  * \param[in]  start     Start time
  * \param[out] end       Where to store end time (\p *end must be NULL
  *                       initially)
  *
  * \return Standard Pacemaker return code
  * \note The caller is responsible for freeing \p *end using crm_time_free().
  */
 int
 pcmk__unpack_duration(const xmlNode *duration, const crm_time_t *start,
                       crm_time_t **end)
 {
     int rc = pcmk_rc_ok;
     const char *id = NULL;
     const char *parent_id = loggable_parent_id(duration);
 
     if ((start == NULL) || (duration == NULL)
         || (end == NULL) || (*end != NULL)) {
         return EINVAL;
     }
 
     // Get duration ID (for logging)
     id = pcmk__xe_id(duration);
     if (pcmk__str_empty(id)) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error instead
          */
         pcmk__config_warn(PCMK_XE_DURATION " subelement of "
                           PCMK_XE_DATE_EXPRESSION " %s has no " PCMK_XA_ID,
                           parent_id);
         id = "without ID";
     }
 
     *end = pcmk_copy_time(start);
 
     ADD_COMPONENT(pcmk__time_years);
     ADD_COMPONENT(pcmk__time_months);
     ADD_COMPONENT(pcmk__time_weeks);
     ADD_COMPONENT(pcmk__time_days);
     ADD_COMPONENT(pcmk__time_hours);
     ADD_COMPONENT(pcmk__time_minutes);
     ADD_COMPONENT(pcmk__time_seconds);
 
     return rc;
 }
 
 /*!
  * \internal
  * \brief Evaluate a range check for a given date/time
  *
  * \param[in]     date_expression  XML of PCMK_XE_DATE_EXPRESSION element
  * \param[in]     id               Expression ID for logging purposes
  * \param[in]     now              Date/time to compare
  * \param[in,out] next_change      If not NULL, set this to when the evaluation
  *                                 will change, if known and earlier than the
  *                                 original value
  *
  * \return Standard Pacemaker return code
  */
 static int
 evaluate_in_range(const xmlNode *date_expression, const char *id,
                   const crm_time_t *now, crm_time_t *next_change)
 {
     crm_time_t *start = NULL;
     crm_time_t *end = NULL;
 
     if (pcmk__xe_get_datetime(date_expression, PCMK_XA_START,
                               &start) != pcmk_rc_ok) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Ignoring " PCMK_XA_START " in "
                           PCMK_XE_DATE_EXPRESSION " %s because it is invalid",
                           id);
     }
 
     if (pcmk__xe_get_datetime(date_expression, PCMK_XA_END,
                               &end) != pcmk_rc_ok) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Ignoring " PCMK_XA_END " in "
                           PCMK_XE_DATE_EXPRESSION " %s because it is invalid",
                           id);
     }
 
     if ((start == NULL) && (end == NULL)) {
         // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                           "passing because in_range requires at least one of "
                           PCMK_XA_START " or " PCMK_XA_END, id);
         return pcmk_rc_undetermined;
     }
 
     if (end == NULL) {
         xmlNode *duration = first_named_child(date_expression,
                                               PCMK_XE_DURATION);
 
         if (duration != NULL) {
             /* @COMPAT When we can break behavioral backward compatibility,
              * return the result of this if not OK
              */
             pcmk__unpack_duration(duration, start, &end);
         }
     }
 
     if ((start != NULL) && (crm_time_compare(now, start) < 0)) {
         pcmk__set_time_if_earlier(next_change, start);
         crm_time_free(start);
         crm_time_free(end);
         return pcmk_rc_before_range;
     }
 
     if (end != NULL) {
         if (crm_time_compare(now, end) > 0) {
             crm_time_free(start);
             crm_time_free(end);
             return pcmk_rc_after_range;
         }
 
         // Evaluation doesn't change until second after end
         if (next_change != NULL) {
             crm_time_add_seconds(end, 1);
             pcmk__set_time_if_earlier(next_change, end);
         }
     }
 
     crm_time_free(start);
     crm_time_free(end);
     return pcmk_rc_within_range;
 }
 
 /*!
  * \internal
  * \brief Evaluate a greater-than check for a given date/time
  *
  * \param[in]     date_expression  XML of PCMK_XE_DATE_EXPRESSION element
  * \param[in]     id               Expression ID for logging purposes
  * \param[in]     now              Date/time to compare
  * \param[in,out] next_change      If not NULL, set this to when the evaluation
  *                                 will change, if known and earlier than the
  *                                 original value
  *
  * \return Standard Pacemaker return code
  */
 static int
 evaluate_gt(const xmlNode *date_expression, const char *id,
             const crm_time_t *now, crm_time_t *next_change)
 {
     crm_time_t *start = NULL;
 
     if (pcmk__xe_get_datetime(date_expression, PCMK_XA_START,
                               &start) != pcmk_rc_ok) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                           "passing because " PCMK_XA_START " is invalid",
                           id);
         return pcmk_rc_undetermined;
     }
 
     if (start == NULL) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                           "passing because " PCMK_VALUE_GT " requires "
                           PCMK_XA_START, id);
         return pcmk_rc_undetermined;
     }
 
     if (crm_time_compare(now, start) > 0) {
         crm_time_free(start);
         return pcmk_rc_within_range;
     }
 
     // Evaluation doesn't change until second after start time
     crm_time_add_seconds(start, 1);
     pcmk__set_time_if_earlier(next_change, start);
     crm_time_free(start);
     return pcmk_rc_before_range;
 }
 
 /*!
  * \internal
  * \brief Evaluate a less-than check for a given date/time
  *
  * \param[in]     date_expression  XML of PCMK_XE_DATE_EXPRESSION element
  * \param[in]     id               Expression ID for logging purposes
  * \param[in]     now              Date/time to compare
  * \param[in,out] next_change      If not NULL, set this to when the evaluation
  *                                 will change, if known and earlier than the
  *                                 original value
  *
  * \return Standard Pacemaker return code
  */
 static int
 evaluate_lt(const xmlNode *date_expression, const char *id,
             const crm_time_t *now, crm_time_t *next_change)
 {
     crm_time_t *end = NULL;
 
     if (pcmk__xe_get_datetime(date_expression, PCMK_XA_END,
                               &end) != pcmk_rc_ok) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                           "passing because " PCMK_XA_END " is invalid", id);
         return pcmk_rc_undetermined;
     }
 
     if (end == NULL) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                           "passing because " PCMK_VALUE_GT " requires "
                           PCMK_XA_END, id);
         return pcmk_rc_undetermined;
     }
 
     if (crm_time_compare(now, end) < 0) {
         pcmk__set_time_if_earlier(next_change, end);
         crm_time_free(end);
         return pcmk_rc_within_range;
     }
 
     crm_time_free(end);
     return pcmk_rc_after_range;
 }
 
 /*!
  * \internal
  * \brief Evaluate a rule's date expression for a given date/time
  *
  * \param[in]     date_expression  XML of a PCMK_XE_DATE_EXPRESSION element
  * \param[in]     now              Time to use for evaluation
  * \param[in,out] next_change      If not NULL, set this to when the evaluation
  *                                 will change, if known and earlier than the
  *                                 original value
  *
  * \return Standard Pacemaker return code (unlike most other evaluation
  *         functions, this can return either pcmk_rc_ok or pcmk_rc_within_range
  *         on success)
  */
 int
 pcmk__evaluate_date_expression(const xmlNode *date_expression,
                                const crm_time_t *now, crm_time_t *next_change)
 {
     const char *id = NULL;
     const char *op = NULL;
     int rc = pcmk_rc_undetermined;
 
     if ((date_expression == NULL) || (now == NULL)) {
         return EINVAL;
     }
 
     // Get expression ID (for logging)
     id = pcmk__xe_id(date_expression);
     if (pcmk__str_empty(id)) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn(PCMK_XE_DATE_EXPRESSION " element has no "
                           PCMK_XA_ID);
         id = "without ID"; // for logging
     }
 
     op = crm_element_value(date_expression, PCMK_XA_OPERATION);
     if (pcmk__str_eq(op, PCMK_VALUE_IN_RANGE,
                      pcmk__str_null_matches|pcmk__str_casei)) {
         rc = evaluate_in_range(date_expression, id, now, next_change);
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_DATE_SPEC, pcmk__str_casei)) {
         xmlNode *date_spec = first_named_child(date_expression,
                                                PCMK_XE_DATE_SPEC);
 
         if (date_spec == NULL) { // Not possible with schema validation enabled
             /* @COMPAT When we can break behavioral backward compatibility,
              * return pcmk_rc_unpack_error
              */
             pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION " %s "
                               "as not passing because " PCMK_VALUE_DATE_SPEC
                               " operations require a " PCMK_XE_DATE_SPEC
                               " subelement", id);
         } else {
             // @TODO set next_change appropriately
             rc = pcmk__evaluate_date_spec(date_spec, now);
         }
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_GT, pcmk__str_casei)) {
         rc = evaluate_gt(date_expression, id, now, next_change);
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_LT, pcmk__str_casei)) {
         rc = evaluate_lt(date_expression, id, now, next_change);
 
     } else { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_unpack_error
          */
         pcmk__config_warn("Treating " PCMK_XE_DATE_EXPRESSION
                           " %s as not passing because '%s' is not a valid "
                           PCMK_XE_OPERATION, op);
     }
 
     crm_trace(PCMK_XE_DATE_EXPRESSION " %s (%s): %s (%d)",
               id, op, pcmk_rc_str(rc), rc);
     return rc;
 }
 
 /*!
  * \internal
  * \brief Go through submatches in a string, either counting how many bytes
  *        would be needed for the expansion, or performing the expansion,
  *        as requested
  *
  * \param[in]  string      String possibly containing submatch variables
  * \param[in]  match       String that matched the regular expression
  * \param[in]  submatches  Regular expression submatches (as set by regexec())
  * \param[in]  nmatches    Number of entries in \p submatches[]
  * \param[out] expansion   If not NULL, expand string here (must be
  *                         pre-allocated to appropriate size)
  * \param[out] nbytes      If not NULL, set to size needed for expansion
  *
  * \return true if any expansion is needed, otherwise false
  */
 static bool
 process_submatches(const char *string, const char *match,
                    const regmatch_t submatches[], int nmatches,
                    char *expansion, size_t *nbytes)
 {
     bool expanded = false;
     const char *src = string;
 
     if (nbytes != NULL) {
         *nbytes = 1; // Include space for terminator
     }
 
     while (*src != '\0') {
         int submatch = 0;
         size_t match_len = 0;
 
         if ((src[0] != '%') || !isdigit(src[1])) {
             /* src does not point to the first character of a %N sequence,
              * so expand this character as-is
              */
             if (expansion != NULL) {
                 *expansion++ = *src;
             }
             if (nbytes != NULL) {
                 ++(*nbytes);
             }
             ++src;
             continue;
         }
 
         submatch = src[1] - '0';
         src += 2; // Skip over %N sequence in source string
         expanded = true; // Expansion will be different from source
 
         // Omit sequence from expansion unless it has a non-empty match
         if ((nmatches <= submatch)                // Not enough submatches
             || (submatches[submatch].rm_so < 0)   // Pattern did not match
             || (submatches[submatch].rm_eo
                 <= submatches[submatch].rm_so)) { // Match was empty
             continue;
         }
 
         match_len = submatches[submatch].rm_eo - submatches[submatch].rm_so;
         if (nbytes != NULL) {
             *nbytes += match_len;
         }
         if (expansion != NULL) {
             memcpy(expansion, match + submatches[submatch].rm_so,
                    match_len);
             expansion += match_len;
         }
     }
 
     return expanded;
 }
 
 /*!
  * \internal
  * \brief Expand any regular expression submatches (%0-%9) in a string
  *
  * \param[in] string      String possibly containing submatch variables
  * \param[in] match       String that matched the regular expression
  * \param[in] submatches  Regular expression submatches (as set by regexec())
  * \param[in] nmatches    Number of entries in \p submatches[]
  *
  * \return Newly allocated string identical to \p string with submatches
  *         expanded on success, or NULL if no expansions were needed
  * \note The caller is responsible for freeing the result with free()
  */
 char *
 pcmk__replace_submatches(const char *string, const char *match,
                          const regmatch_t submatches[], int nmatches)
 {
     size_t nbytes = 0;
     char *result = NULL;
 
     if (pcmk__str_empty(string)) {
         return NULL; // Nothing to expand
     }
 
     // Calculate how much space will be needed for expanded string
     if (!process_submatches(string, match, submatches, nmatches, NULL,
                             &nbytes)) {
         return NULL; // No expansions needed
     }
 
     // Allocate enough space for expanded string
     result = calloc(nbytes, sizeof(char));
     CRM_ASSERT(result != NULL);
 
     // Expand submatches
     (void) process_submatches(string, match, submatches, nmatches, result,
                               NULL);
     return result;
 }
 
 /*!
  * \internal
  * \brief Parse a comparison type from a string
  *
  * \param[in] op  String with comparison type (valid values are
  *                \c PCMK_VALUE_DEFINED, \c PCMK_VALUE_NOT_DEFINED,
  *                \c PCMK_VALUE_EQ, \c PCMK_VALUE_NE,
  *                \c PCMK_VALUE_LT, \c PCMK_VALUE_LTE,
  *                \c PCMK_VALUE_GT, or \c PCMK_VALUE_GTE)
  *
  * \return Comparison type corresponding to \p op
  */
 enum pcmk__comparison
 pcmk__parse_comparison(const char *op)
 {
     if (pcmk__str_eq(op, PCMK_VALUE_DEFINED, pcmk__str_casei)) {
         return pcmk__comparison_defined;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_NOT_DEFINED, pcmk__str_casei)) {
         return pcmk__comparison_undefined;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_EQ, pcmk__str_casei)) {
         return pcmk__comparison_eq;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_NE, pcmk__str_casei)) {
         return pcmk__comparison_ne;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_LT, pcmk__str_casei)) {
         return pcmk__comparison_lt;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_LTE, pcmk__str_casei)) {
         return pcmk__comparison_lte;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_GT, pcmk__str_casei)) {
         return pcmk__comparison_gt;
 
     } else if (pcmk__str_eq(op, PCMK_VALUE_GTE, pcmk__str_casei)) {
         return pcmk__comparison_gte;
     }
 
     return pcmk__comparison_unknown;
 }
 
 /*!
  * \internal
  * \brief Parse a value type from a string
  *
  * \param[in] type    String with value type (valid values are NULL,
  *                    \c PCMK_VALUE_STRING, \c PCMK_VALUE_INTEGER,
  *                    \c PCMK_VALUE_NUMBER, and \c PCMK_VALUE_VERSION)
  * \param[in] op      Operation type (used only to select default)
  * \param[in] value1  First value being compared (used only to select default)
  * \param[in] value2  Second value being compared (used only to select default)
  */
 enum pcmk__type
 pcmk__parse_type(const char *type, enum pcmk__comparison op,
                  const char *value1, const char *value2)
 {
     if (type == NULL) {
         switch (op) {
             case pcmk__comparison_lt:
             case pcmk__comparison_lte:
             case pcmk__comparison_gt:
             case pcmk__comparison_gte:
                 if (((value1 != NULL) && (strchr(value1, '.') != NULL))
                     || ((value2 != NULL) && (strchr(value2, '.') != NULL))) {
                     return pcmk__type_number;
                 }
                 return pcmk__type_integer;
 
             default:
                 return pcmk__type_string;
         }
     }
 
     if (pcmk__str_eq(type, PCMK_VALUE_STRING, pcmk__str_casei)) {
         return pcmk__type_string;
 
     } else if (pcmk__str_eq(type, PCMK_VALUE_INTEGER, pcmk__str_casei)) {
         return pcmk__type_integer;
 
     } else if (pcmk__str_eq(type, PCMK_VALUE_NUMBER, pcmk__str_casei)) {
         return pcmk__type_number;
 
     } else if (pcmk__str_eq(type, PCMK_VALUE_VERSION, pcmk__str_casei)) {
         return pcmk__type_version;
     }
 
     return pcmk__type_unknown;
 }
+
+/*!
+ * \internal
+ * \brief   Compare two values in a rule's node attribute expression
+ *
+ * \param[in]   l_val   Value on left-hand side of comparison
+ * \param[in]   r_val   Value on right-hand side of comparison
+ * \param[in]   type    How to interpret the values
+ *
+ * \return  -1 if <tt>(l_val < r_val)</tt>,
+ *           0 if <tt>(l_val == r_val)</tt>,
+ *           1 if <tt>(l_val > r_val)</tt>
+ */
+int
+pcmk__cmp_by_type(const char *l_val, const char *r_val, enum pcmk__type type)
+{
+    int cmp = 0;
+
+    if (l_val != NULL && r_val != NULL) {
+        switch (type) {
+            case pcmk__type_string:
+                cmp = strcasecmp(l_val, r_val);
+                break;
+
+            case pcmk__type_integer:
+                {
+                    long long l_val_num;
+                    int rc1 = pcmk__scan_ll(l_val, &l_val_num, 0LL);
+
+                    long long r_val_num;
+                    int rc2 = pcmk__scan_ll(r_val, &r_val_num, 0LL);
+
+                    if ((rc1 == pcmk_rc_ok) && (rc2 == pcmk_rc_ok)) {
+                        if (l_val_num < r_val_num) {
+                            cmp = -1;
+                        } else if (l_val_num > r_val_num) {
+                            cmp = 1;
+                        } else {
+                            cmp = 0;
+                        }
+
+                    } else {
+                        crm_debug("Integer parse error. Comparing %s and %s "
+                                  "as strings", l_val, r_val);
+                        cmp = pcmk__cmp_by_type(l_val, r_val,
+                                                pcmk__type_string);
+                    }
+                }
+                break;
+
+            case pcmk__type_number:
+                {
+                    double l_val_num;
+                    double r_val_num;
+
+                    int rc1 = pcmk__scan_double(l_val, &l_val_num, NULL, NULL);
+                    int rc2 = pcmk__scan_double(r_val, &r_val_num, NULL, NULL);
+
+                    if (rc1 == pcmk_rc_ok && rc2 == pcmk_rc_ok) {
+                        if (l_val_num < r_val_num) {
+                            cmp = -1;
+                        } else if (l_val_num > r_val_num) {
+                            cmp = 1;
+                        } else {
+                            cmp = 0;
+                        }
+
+                    } else {
+                        crm_debug("Floating-point parse error. Comparing %s "
+                                  "and %s as strings", l_val, r_val);
+                        cmp = pcmk__cmp_by_type(l_val, r_val,
+                                                pcmk__type_string);
+                    }
+                }
+                break;
+
+            case pcmk__type_version:
+                cmp = compare_version(l_val, r_val);
+                break;
+
+            default:
+                break;
+        }
+
+    } else if (l_val == NULL && r_val == NULL) {
+        cmp = 0;
+    } else if (r_val == NULL) {
+        cmp = 1;
+    } else {    // l_val == NULL && r_val != NULL
+        cmp = -1;
+    }
+
+    return cmp;
+}
diff --git a/lib/pengine/rules.c b/lib/pengine/rules.c
index 95a593d55f..e71020b2a8 100644
--- a/lib/pengine/rules.c
+++ b/lib/pengine/rules.c
@@ -1,964 +1,869 @@
 /*
  * 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 <glib.h>
 
 #include <crm/crm.h>
 #include <crm/common/xml.h>
 #include <crm/pengine/rules.h>
 
 #include <crm/common/iso8601_internal.h>
 #include <crm/common/nvpair_internal.h>
 #include <crm/common/rules_internal.h>
 #include <crm/common/xml_internal.h>
 #include <crm/pengine/internal.h>
 #include <crm/pengine/rules_internal.h>
 
 #include <sys/types.h>
 #include <regex.h>
 
 CRM_TRACE_INIT_DATA(pe_rules);
 
 /*!
  * \brief Evaluate any rules contained by given XML element
  *
  * \param[in,out] xml          XML element to check for rules
  * \param[in]     node_hash    Node attributes to use to evaluate expressions
  * \param[in]     now          Time to use when evaluating expressions
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return TRUE if no rules, or any of rules present is in effect, else FALSE
  */
 gboolean
 pe_evaluate_rules(xmlNode *ruleset, GHashTable *node_hash, crm_time_t *now,
                   crm_time_t *next_change)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = NULL,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     return pe_eval_rules(ruleset, &rule_data, next_change);
 }
 
 gboolean
 pe_test_rule(xmlNode *rule, GHashTable *node_hash, enum rsc_role_e role,
              crm_time_t *now, crm_time_t *next_change,
              pe_match_data_t *match_data)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = match_data,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     return pe_eval_expr(rule, &rule_data, next_change);
 }
 
 /*!
  * \brief Evaluate one rule subelement (pass/fail)
  *
  * A rule element may contain another rule, a node attribute expression, or a
  * date expression. Given any one of those, evaluate it and return whether it
  * passed.
  *
  * \param[in,out] expr         Rule subelement XML
  * \param[in]     node_hash    Node attributes to use when evaluating expression
  * \param[in]     role         Ignored (deprecated)
  * \param[in]     now          Time to use when evaluating expression
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  * \param[in]     match_data   If not NULL, resource back-references and params
  *
  * \return TRUE if expression is in effect under given conditions, else FALSE
  */
 gboolean
 pe_test_expression(xmlNode *expr, GHashTable *node_hash, enum rsc_role_e role,
                    crm_time_t *now, crm_time_t *next_change,
                    pe_match_data_t *match_data)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = match_data,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     return pe_eval_subexpr(expr, &rule_data, next_change);
 }
 
 // Information about a block of nvpair elements
 typedef struct sorted_set_s {
     int score;                  // This block's score for sorting
     const char *name;           // This block's ID
     const char *special_name;   // ID that should sort first
     xmlNode *attr_set;          // This block
     gboolean overwrite;         // Whether existing values will be overwritten
 } sorted_set_t;
 
 static gint
 sort_pairs(gconstpointer a, gconstpointer b)
 {
     const sorted_set_t *pair_a = a;
     const sorted_set_t *pair_b = b;
 
     if (a == NULL && b == NULL) {
         return 0;
     } else if (a == NULL) {
         return 1;
     } else if (b == NULL) {
         return -1;
     }
 
     if (pcmk__str_eq(pair_a->name, pair_a->special_name, pcmk__str_casei)) {
         return -1;
 
     } else if (pcmk__str_eq(pair_b->name, pair_a->special_name, pcmk__str_casei)) {
         return 1;
     }
 
     /* If we're overwriting values, we want lowest score first, so the highest
      * score is processed last; if we're not overwriting values, we want highest
      * score first, so nothing else overwrites it.
      */
     if (pair_a->score < pair_b->score) {
         return pair_a->overwrite? -1 : 1;
     } else if (pair_a->score > pair_b->score) {
         return pair_a->overwrite? 1 : -1;
     }
     return 0;
 }
 
 static void
 populate_hash(xmlNode * nvpair_list, GHashTable * hash, gboolean overwrite, xmlNode * top)
 {
     const char *name = NULL;
     const char *value = NULL;
     const char *old_value = NULL;
     xmlNode *list = nvpair_list;
     xmlNode *an_attr = NULL;
 
     if (pcmk__xe_is(list->children, PCMK__XE_ATTRIBUTES)) {
         list = list->children;
     }
 
     for (an_attr = pcmk__xe_first_child(list); an_attr != NULL;
          an_attr = pcmk__xe_next(an_attr)) {
 
         if (pcmk__xe_is(an_attr, PCMK_XE_NVPAIR)) {
             xmlNode *ref_nvpair = expand_idref(an_attr, top);
 
             name = crm_element_value(an_attr, PCMK_XA_NAME);
             if ((name == NULL) && (ref_nvpair != NULL)) {
                 name = crm_element_value(ref_nvpair, PCMK_XA_NAME);
             }
 
             value = crm_element_value(an_attr, PCMK_XA_VALUE);
             if ((value == NULL) && (ref_nvpair != NULL)) {
                 value = crm_element_value(ref_nvpair, PCMK_XA_VALUE);
             }
 
             if (name == NULL || value == NULL) {
                 continue;
             }
 
             old_value = g_hash_table_lookup(hash, name);
 
             if (pcmk__str_eq(value, "#default", pcmk__str_casei)) {
                 if (old_value) {
                     crm_trace("Letting %s default (removing explicit value \"%s\")",
                               name, value);
                     g_hash_table_remove(hash, name);
                 }
                 continue;
 
             } else if (old_value == NULL) {
                 crm_trace("Setting %s=\"%s\"", name, value);
                 pcmk__insert_dup(hash, name, value);
 
             } else if (overwrite) {
                 crm_trace("Setting %s=\"%s\" (overwriting old value \"%s\")",
                           name, value, old_value);
                 pcmk__insert_dup(hash, name, value);
             }
         }
     }
 }
 
 typedef struct unpack_data_s {
     gboolean overwrite;
     void *hash;
     crm_time_t *next_change;
     const pe_rule_eval_data_t *rule_data;
     xmlNode *top;
 } unpack_data_t;
 
 static void
 unpack_attr_set(gpointer data, gpointer user_data)
 {
     sorted_set_t *pair = data;
     unpack_data_t *unpack_data = user_data;
 
     if (!pe_eval_rules(pair->attr_set, unpack_data->rule_data,
                        unpack_data->next_change)) {
         return;
     }
 
     crm_trace("Adding attributes from %s (score %d) %s overwrite",
               pair->name, pair->score,
               (unpack_data->overwrite? "with" : "without"));
     populate_hash(pair->attr_set, unpack_data->hash, unpack_data->overwrite, unpack_data->top);
 }
 
 /*!
  * \internal
  * \brief Create a sorted list of nvpair blocks
  *
  * \param[in,out] top           XML document root (used to expand id-ref's)
  * \param[in]     xml_obj       XML element containing blocks of nvpair elements
  * \param[in]     set_name      If not NULL, only get blocks of this element
  * \param[in]     always_first  If not NULL, sort block with this ID as first
  *
  * \return List of sorted_set_t entries for nvpair blocks
  */
 static GList *
 make_pairs(xmlNode *top, const xmlNode *xml_obj, const char *set_name,
            const char *always_first, gboolean overwrite)
 {
     GList *unsorted = NULL;
 
     if (xml_obj == NULL) {
         return NULL;
     }
     for (xmlNode *attr_set = pcmk__xe_first_child(xml_obj); attr_set != NULL;
          attr_set = pcmk__xe_next(attr_set)) {
 
         if ((set_name == NULL) || pcmk__xe_is(attr_set, set_name)) {
             const char *score = NULL;
             sorted_set_t *pair = NULL;
             xmlNode *expanded_attr_set = expand_idref(attr_set, top);
 
             if (expanded_attr_set == NULL) {
                 continue; // Not possible with schema validation enabled
             }
 
             pair = calloc(1, sizeof(sorted_set_t));
             pair->name = pcmk__xe_id(expanded_attr_set);
             pair->special_name = always_first;
             pair->attr_set = expanded_attr_set;
             pair->overwrite = overwrite;
 
             score = crm_element_value(expanded_attr_set, PCMK_XA_SCORE);
             pair->score = char2score(score);
 
             unsorted = g_list_prepend(unsorted, pair);
         }
     }
     return g_list_sort(unsorted, sort_pairs);
 }
 
 /*!
  * \brief Extract nvpair blocks contained by an XML element into a hash table
  *
  * \param[in,out] top           XML document root (used to expand id-ref's)
  * \param[in]     xml_obj       XML element containing blocks of nvpair elements
  * \param[in]     set_name      If not NULL, only use blocks of this element
  * \param[in]     rule_data     Matching parameters to use when unpacking
  * \param[out]    hash          Where to store extracted name/value pairs
  * \param[in]     always_first  If not NULL, process block with this ID first
  * \param[in]     overwrite     Whether to replace existing values with same name
  * \param[out]    next_change   If not NULL, set to when evaluation will change
  */
 void
 pe_eval_nvpairs(xmlNode *top, const xmlNode *xml_obj, const char *set_name,
                 const pe_rule_eval_data_t *rule_data, GHashTable *hash,
                 const char *always_first, gboolean overwrite,
                 crm_time_t *next_change)
 {
     GList *pairs = make_pairs(top, xml_obj, set_name, always_first, overwrite);
 
     if (pairs) {
         unpack_data_t data = {
             .hash = hash,
             .overwrite = overwrite,
             .next_change = next_change,
             .top = top,
             .rule_data = rule_data
         };
 
         g_list_foreach(pairs, unpack_attr_set, &data);
         g_list_free_full(pairs, free);
     }
 }
 
 /*!
  * \brief Extract nvpair blocks contained by an XML element into a hash table
  *
  * \param[in,out] top           XML document root (used to expand id-ref's)
  * \param[in]     xml_obj       XML element containing blocks of nvpair elements
  * \param[in]     set_name      Element name to identify nvpair blocks
  * \param[in]     node_hash     Node attributes to use when evaluating rules
  * \param[out]    hash          Where to store extracted name/value pairs
  * \param[in]     always_first  If not NULL, process block with this ID first
  * \param[in]     overwrite     Whether to replace existing values with same name
  * \param[in]     now           Time to use when evaluating rules
  * \param[out]    next_change   If not NULL, set to when evaluation will change
  */
 void
 pe_unpack_nvpairs(xmlNode *top, const xmlNode *xml_obj, const char *set_name,
                   GHashTable *node_hash, GHashTable *hash,
                   const char *always_first, gboolean overwrite,
                   crm_time_t *now, crm_time_t *next_change)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = NULL,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     pe_eval_nvpairs(top, xml_obj, set_name, &rule_data, hash,
                     always_first, overwrite, next_change);
 }
 
 /*!
  * \brief Evaluate rules
  *
  * \param[in,out] ruleset      XML possibly containing rule sub-elements
  * \param[in]     rule_data
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return TRUE if there are no rules or
  */
 gboolean
 pe_eval_rules(xmlNode *ruleset, const pe_rule_eval_data_t *rule_data,
               crm_time_t *next_change)
 {
     // If there are no rules, pass by default
     gboolean ruleset_default = TRUE;
 
     for (xmlNode *rule = first_named_child(ruleset, PCMK_XE_RULE);
          rule != NULL; rule = crm_next_same_xml(rule)) {
 
         ruleset_default = FALSE;
         if (pe_eval_expr(rule, rule_data, next_change)) {
             /* Only the deprecated PCMK__XE_LIFETIME element of location
              * constraints may contain more than one rule at the top level --
              * the schema limits a block of nvpairs to a single top-level rule.
              * So, this effectively means that a lifetime is active if any rule
              * it contains is active.
              */
             return TRUE;
         }
     }
 
     return ruleset_default;
 }
 
 /*!
  * \brief Evaluate all of a rule's expressions
  *
  * \param[in,out] rule         XML containing a rule definition or its id-ref
  * \param[in]     rule_data    Matching parameters to check against rule
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return TRUE if \p rule_data passes \p rule, otherwise FALSE
  */
 gboolean
 pe_eval_expr(xmlNode *rule, const pe_rule_eval_data_t *rule_data,
              crm_time_t *next_change)
 {
     xmlNode *expr = NULL;
     gboolean test = TRUE;
     gboolean empty = TRUE;
     gboolean passed = TRUE;
     gboolean do_and = TRUE;
     const char *value = NULL;
 
     rule = expand_idref(rule, NULL);
     if (rule == NULL) {
         return FALSE; // Not possible with schema validation enabled
     }
 
     value = crm_element_value(rule, PCMK_XA_BOOLEAN_OP);
     if (pcmk__str_eq(value, PCMK_VALUE_OR, pcmk__str_casei)) {
         do_and = FALSE;
         passed = FALSE;
 
     } else if (!pcmk__str_eq(value, PCMK_VALUE_AND,
                              pcmk__str_null_matches|pcmk__str_casei)) {
         pcmk__config_warn("Rule %s has invalid " PCMK_XA_BOOLEAN_OP
                           " value '%s', using default ('" PCMK_VALUE_AND "')",
                           pcmk__xe_id(rule), value);
     }
 
     crm_trace("Testing rule %s", pcmk__xe_id(rule));
     for (expr = pcmk__xe_first_child(rule); expr != NULL;
          expr = pcmk__xe_next(expr)) {
 
         test = pe_eval_subexpr(expr, rule_data, next_change);
         empty = FALSE;
 
         if (test && do_and == FALSE) {
             crm_trace("Expression %s/%s passed",
                       pcmk__xe_id(rule), pcmk__xe_id(expr));
             return TRUE;
 
         } else if (test == FALSE && do_and) {
             crm_trace("Expression %s/%s failed",
                       pcmk__xe_id(rule), pcmk__xe_id(expr));
             return FALSE;
         }
     }
 
     if (empty) {
         pcmk__config_err("Ignoring rule %s because it contains no expressions",
                          pcmk__xe_id(rule));
     }
 
     crm_trace("Rule %s %s", pcmk__xe_id(rule), passed ? "passed" : "failed");
     return passed;
 }
 
 /*!
  * \brief Evaluate a single rule expression, including any subexpressions
  *
  * \param[in,out] expr         XML containing a rule expression
  * \param[in]     rule_data    Matching parameters to check against expression
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return TRUE if \p rule_data passes \p expr, otherwise FALSE
  */
 gboolean
 pe_eval_subexpr(xmlNode *expr, const pe_rule_eval_data_t *rule_data,
                 crm_time_t *next_change)
 {
     gboolean accept = FALSE;
     const char *uname = NULL;
 
     switch (pcmk__expression_type(expr)) {
         case pcmk__subexpr_rule:
             accept = pe_eval_expr(expr, rule_data, next_change);
             break;
         case pcmk__subexpr_attribute:
         case pcmk__subexpr_location:
             /* these expressions can never succeed if there is
              * no node to compare with
              */
             if (rule_data->node_hash != NULL) {
                 accept = pe__eval_attr_expr(expr, rule_data);
             }
             break;
 
         case pcmk__subexpr_datetime:
             switch (pcmk__evaluate_date_expression(expr, rule_data->now,
                                                    next_change)) {
                 case pcmk_rc_within_range:
                 case pcmk_rc_ok:
                     accept = TRUE;
                     break;
 
                 default:
                     accept = FALSE;
                     break;
             }
             break;
 
         case pcmk__subexpr_resource:
             accept = pe__eval_rsc_expr(expr, rule_data);
             break;
 
         case pcmk__subexpr_operation:
             accept = pe__eval_op_expr(expr, rule_data);
             break;
 
         default:
             CRM_CHECK(FALSE /* bad type */ , return FALSE);
             accept = FALSE;
     }
     if (rule_data->node_hash) {
         uname = g_hash_table_lookup(rule_data->node_hash, CRM_ATTR_UNAME);
     }
 
     crm_trace("Expression %s %s on %s",
               pcmk__xe_id(expr), (accept? "passed" : "failed"),
               pcmk__s(uname, "all nodes"));
     return accept;
 }
 
-/*!
- * \internal
- * \brief   Compare two values in a rule's node attribute expression
- *
- * \param[in]   l_val   Value on left-hand side of comparison
- * \param[in]   r_val   Value on right-hand side of comparison
- * \param[in]   type    How to interpret the values
- *
- * \return  -1 if <tt>(l_val < r_val)</tt>,
- *           0 if <tt>(l_val == r_val)</tt>,
- *           1 if <tt>(l_val > r_val)</tt>
- */
-static int
-compare_attr_expr_vals(const char *l_val, const char *r_val,
-                       enum pcmk__type type)
-{
-    int cmp = 0;
-
-    if (l_val != NULL && r_val != NULL) {
-        switch (type) {
-            case pcmk__type_string:
-                cmp = strcasecmp(l_val, r_val);
-                break;
-
-            case pcmk__type_integer:
-                {
-                    long long l_val_num;
-                    int rc1 = pcmk__scan_ll(l_val, &l_val_num, 0LL);
-
-                    long long r_val_num;
-                    int rc2 = pcmk__scan_ll(r_val, &r_val_num, 0LL);
-
-                    if ((rc1 == pcmk_rc_ok) && (rc2 == pcmk_rc_ok)) {
-                        if (l_val_num < r_val_num) {
-                            cmp = -1;
-                        } else if (l_val_num > r_val_num) {
-                            cmp = 1;
-                        } else {
-                            cmp = 0;
-                        }
-
-                    } else {
-                        crm_debug("Integer parse error. Comparing %s and %s "
-                                  "as strings", l_val, r_val);
-                        cmp = compare_attr_expr_vals(l_val, r_val,
-                                                     pcmk__type_string);
-                    }
-                }
-                break;
-
-            case pcmk__type_number:
-                {
-                    double l_val_num;
-                    double r_val_num;
-
-                    int rc1 = pcmk__scan_double(l_val, &l_val_num, NULL, NULL);
-                    int rc2 = pcmk__scan_double(r_val, &r_val_num, NULL, NULL);
-
-                    if (rc1 == pcmk_rc_ok && rc2 == pcmk_rc_ok) {
-                        if (l_val_num < r_val_num) {
-                            cmp = -1;
-                        } else if (l_val_num > r_val_num) {
-                            cmp = 1;
-                        } else {
-                            cmp = 0;
-                        }
-
-                    } else {
-                        crm_debug("Floating-point parse error. Comparing %s "
-                                  "and %s as strings", l_val, r_val);
-                        cmp = compare_attr_expr_vals(l_val, r_val,
-                                                     pcmk__type_string);
-                    }
-                }
-                break;
-
-            case pcmk__type_version:
-                cmp = compare_version(l_val, r_val);
-                break;
-
-            default:
-                break;
-        }
-
-    } else if (l_val == NULL && r_val == NULL) {
-        cmp = 0;
-    } else if (r_val == NULL) {
-        cmp = 1;
-    } else {    // l_val == NULL && r_val != NULL
-        cmp = -1;
-    }
-
-    return cmp;
-}
-
 /*!
  * \internal
  * \brief Check whether an attribute expression evaluates to \c true
  *
  * \param[in]   l_val   Value on left-hand side of comparison
  * \param[in]   r_val   Value on right-hand side of comparison
  * \param[in]   type    How to interpret the values
  * \param[in]   op      Type of comparison.
  *
  * \return  \c true if expression evaluates to \c true, \c false
  *          otherwise
  */
 static bool
 accept_attr_expr(const char *l_val, const char *r_val, enum pcmk__type type,
                  enum pcmk__comparison op)
 {
     int cmp;
 
     switch (op) {
         case pcmk__comparison_defined:
             return (l_val != NULL);
 
         case pcmk__comparison_undefined:
             return (l_val == NULL);
 
         default:
             break;
     }
 
-    cmp = compare_attr_expr_vals(l_val, r_val, type);
+    cmp = pcmk__cmp_by_type(l_val, r_val, type);
 
     switch (op) {
         case pcmk__comparison_eq:
             return (cmp == 0);
 
         case pcmk__comparison_ne:
             return (cmp != 0);
 
         default:
             break;
     }
 
     if ((l_val == NULL) || (r_val == NULL)) {
         // The comparison is meaningless from this point on
         return false;
     }
 
     switch (op) {
         case pcmk__comparison_lt:
             return (cmp < 0);
 
         case pcmk__comparison_lte:
             return (cmp <= 0);
 
         case pcmk__comparison_gt:
             return (cmp > 0);
 
         case pcmk__comparison_gte:
             return (cmp >= 0);
 
         default: // Not possible with schema validation enabled
             return false;
     }
 }
 
 /*!
  * \internal
  * \brief Get correct value according to \c PCMK_XA_VALUE_SOURCE
  *
  * \param[in] expr_id       Rule expression ID (for logging only)
  * \param[in] value         value given in rule expression
  * \param[in] value_source  \c PCMK_XA_VALUE_SOURCE given in rule expressions
  * \param[in] match_data    If not NULL, resource back-references and params
  */
 static const char *
 expand_value_source(const char *expr_id, const char *value,
                     const char *value_source, const pe_match_data_t *match_data)
 {
     GHashTable *table = NULL;
 
     if (pcmk__str_empty(value)) {
         return NULL; // value_source is irrelevant
 
     } else if (pcmk__str_eq(value_source, PCMK_VALUE_PARAM, pcmk__str_casei)) {
         table = match_data->params;
 
     } else if (pcmk__str_eq(value_source, PCMK_VALUE_META, pcmk__str_casei)) {
         table = match_data->meta;
 
     } else { // literal
         if (!pcmk__str_eq(value_source, PCMK_VALUE_LITERAL,
                           pcmk__str_null_matches|pcmk__str_casei)) {
 
             pcmk__config_warn("Expression %s has invalid " PCMK_XA_VALUE_SOURCE
                               " value '%s', using default "
                               "('" PCMK_VALUE_LITERAL "')",
                               pcmk__s(expr_id, "without ID"), value_source);
         }
         return value;
     }
 
     if (table == NULL) {
         return NULL;
     }
     return (const char *) g_hash_table_lookup(table, value);
 }
 
 /*!
  * \internal
  * \brief Evaluate a node attribute expression based on #uname, #id, #kind,
  *        or a generic node attribute
  *
  * \param[in] expr       XML of rule expression
  * \param[in] rule_data  The match_data and node_hash members are used
  *
  * \return TRUE if rule_data satisfies the expression, FALSE otherwise
  */
 gboolean
 pe__eval_attr_expr(const xmlNode *expr, const pe_rule_eval_data_t *rule_data)
 {
     gboolean attr_allocated = FALSE;
     const char *h_val = NULL;
 
     const char *id = pcmk__xe_id(expr);
     const char *attr = crm_element_value(expr, PCMK_XA_ATTRIBUTE);
     const char *op = NULL;
     const char *type_s = crm_element_value(expr, PCMK_XA_TYPE);
     const char *value = crm_element_value(expr, PCMK_XA_VALUE);
     const char *value_source = crm_element_value(expr, PCMK_XA_VALUE_SOURCE);
 
     enum pcmk__comparison comparison = pcmk__comparison_unknown;
     enum pcmk__type type = pcmk__type_unknown;
 
     if (attr == NULL) {
         pcmk__config_err("Expression %s invalid: " PCMK_XA_ATTRIBUTE
                          " not specified", pcmk__s(id, "without ID"));
         return FALSE;
     }
 
     // Get and validate operation
     op = crm_element_value(expr, PCMK_XA_OPERATION);
     comparison = pcmk__parse_comparison(op);
     if (comparison == pcmk__comparison_unknown) {
         // Not possible with schema validation enabled
         if (op == NULL) {
             pcmk__config_err("Treating expression %s as not passing "
                              "because it has no " PCMK_XA_OPERATION,
                              pcmk__s(id, "without ID"));
         } else {
             pcmk__config_err("Treating expression %s as not passing "
                              "because '%s' is not a valid " PCMK_XA_OPERATION,
                              pcmk__s(id, "without ID"), op);
         }
         return FALSE;
     }
 
     if (rule_data->match_data != NULL) {
         // Expand any regular expression submatches (%0-%9) in attribute name
         if (rule_data->match_data->re != NULL) {
             const char *match = rule_data->match_data->re->string;
             const regmatch_t *submatches = rule_data->match_data->re->pmatch;
             const int nmatches = rule_data->match_data->re->nregs;
             char *resolved_attr = pcmk__replace_submatches(attr, match,
                                                            submatches,
                                                            nmatches);
 
             if (resolved_attr != NULL) {
                 attr = (const char *) resolved_attr;
                 attr_allocated = TRUE;
             }
         }
 
         // Get value appropriate to PCMK_XA_VALUE_SOURCE
         value = expand_value_source(id, value, value_source,
                                     rule_data->match_data);
     }
 
     if (rule_data->node_hash != NULL) {
         h_val = (const char *)g_hash_table_lookup(rule_data->node_hash, attr);
     }
 
     if (attr_allocated) {
         free((char *)attr);
         attr = NULL;
     }
 
     // Get and validate value type (after expanding value)
     type = pcmk__parse_type(type_s, comparison, h_val, value);
     if (type == pcmk__type_unknown) {
         /* Not possible with schema validation enabled
          *
          * @COMPAT When we can break behavioral backward compatibility, treat
          * the expression as not passing.
          */
         pcmk__config_warn("Non-empty node attribute values will be treated as "
                           "equal for expression %s because '%s' is not a "
                           "valid type", pcmk__s(id, "without ID"), type);
     }
 
     return accept_attr_expr(h_val, value, type, comparison);
 }
 
 gboolean
 pe__eval_op_expr(const xmlNode *expr, const pe_rule_eval_data_t *rule_data)
 {
     const char *name = crm_element_value(expr, PCMK_XA_NAME);
     const char *interval_s = crm_element_value(expr, PCMK_META_INTERVAL);
     guint interval_ms = 0U;
 
     crm_trace("Testing op_defaults expression: %s", pcmk__xe_id(expr));
 
     if (rule_data->op_data == NULL) {
         crm_trace("No operations data provided");
         return FALSE;
     }
 
     if (pcmk_parse_interval_spec(interval_s, &interval_ms) != pcmk_rc_ok) {
         crm_trace("Could not parse interval: %s", interval_s);
         return FALSE;
     }
 
     if ((interval_s != NULL) && (interval_ms != rule_data->op_data->interval)) {
         crm_trace("Interval doesn't match: %d != %d",
                   interval_ms, rule_data->op_data->interval);
         return FALSE;
     }
 
     if (!pcmk__str_eq(name, rule_data->op_data->op_name, pcmk__str_none)) {
         crm_trace("Name doesn't match: %s != %s", name, rule_data->op_data->op_name);
         return FALSE;
     }
 
     return TRUE;
 }
 
 gboolean
 pe__eval_rsc_expr(const xmlNode *expr, const pe_rule_eval_data_t *rule_data)
 {
     const char *class = crm_element_value(expr, PCMK_XA_CLASS);
     const char *provider = crm_element_value(expr, PCMK_XA_PROVIDER);
     const char *type = crm_element_value(expr, PCMK_XA_TYPE);
 
     crm_trace("Testing rsc_defaults expression: %s", pcmk__xe_id(expr));
 
     if (rule_data->rsc_data == NULL) {
         crm_trace("No resource data provided");
         return FALSE;
     }
 
     if (class != NULL &&
         !pcmk__str_eq(class, rule_data->rsc_data->standard, pcmk__str_none)) {
         crm_trace("Class doesn't match: %s != %s", class, rule_data->rsc_data->standard);
         return FALSE;
     }
 
     if ((provider == NULL && rule_data->rsc_data->provider != NULL) ||
         (provider != NULL && rule_data->rsc_data->provider == NULL) ||
         !pcmk__str_eq(provider, rule_data->rsc_data->provider, pcmk__str_none)) {
         crm_trace("Provider doesn't match: %s != %s", provider, rule_data->rsc_data->provider);
         return FALSE;
     }
 
     if (type != NULL &&
         !pcmk__str_eq(type, rule_data->rsc_data->agent, pcmk__str_none)) {
         crm_trace("Agent doesn't match: %s != %s", type, rule_data->rsc_data->agent);
         return FALSE;
     }
 
     return TRUE;
 }
 
 // Deprecated functions kept only for backward API compatibility
 // LCOV_EXCL_START
 
 #include <crm/pengine/rules_compat.h>
 
 gboolean
 test_ruleset(xmlNode *ruleset, GHashTable *node_hash, crm_time_t *now)
 {
     return pe_evaluate_rules(ruleset, node_hash, now, NULL);
 }
 
 gboolean
 test_rule(xmlNode * rule, GHashTable * node_hash, enum rsc_role_e role, crm_time_t * now)
 {
     return pe_test_rule(rule, node_hash, role, now, NULL, NULL);
 }
 
 gboolean
 pe_test_rule_re(xmlNode * rule, GHashTable * node_hash, enum rsc_role_e role, crm_time_t * now, pe_re_match_data_t * re_match_data)
 {
     pe_match_data_t match_data = {
                                     .re = re_match_data,
                                     .params = NULL,
                                     .meta = NULL,
                                  };
     return pe_test_rule(rule, node_hash, role, now, NULL, &match_data);
 }
 
 gboolean
 pe_test_rule_full(xmlNode *rule, GHashTable *node_hash, enum rsc_role_e role,
                   crm_time_t *now, pe_match_data_t *match_data)
 {
     return pe_test_rule(rule, node_hash, role, now, NULL, match_data);
 }
 
 gboolean
 test_expression(xmlNode * expr, GHashTable * node_hash, enum rsc_role_e role, crm_time_t * now)
 {
     return pe_test_expression(expr, node_hash, role, now, NULL, NULL);
 }
 
 gboolean
 pe_test_expression_re(xmlNode * expr, GHashTable * node_hash, enum rsc_role_e role, crm_time_t * now, pe_re_match_data_t * re_match_data)
 {
     pe_match_data_t match_data = {
                                     .re = re_match_data,
                                     .params = NULL,
                                     .meta = NULL,
                                  };
     return pe_test_expression(expr, node_hash, role, now, NULL, &match_data);
 }
 
 gboolean
 pe_test_expression_full(xmlNode *expr, GHashTable *node_hash,
                         enum rsc_role_e role, crm_time_t *now,
                         pe_match_data_t *match_data)
 {
     return pe_test_expression(expr, node_hash, role, now, NULL, match_data);
 }
 
 void
 unpack_instance_attributes(xmlNode *top, xmlNode *xml_obj, const char *set_name,
                            GHashTable *node_hash, GHashTable *hash,
                            const char *always_first, gboolean overwrite,
                            crm_time_t *now)
 {
     pe_rule_eval_data_t rule_data = {
         .node_hash = node_hash,
         .now = now,
         .match_data = NULL,
         .rsc_data = NULL,
         .op_data = NULL
     };
 
     pe_eval_nvpairs(top, xml_obj, set_name, &rule_data, hash, always_first,
                     overwrite, NULL);
 }
 
 enum expression_type
 find_expression_type(xmlNode *expr)
 {
     return pcmk__expression_type(expr);
 }
 
 char *
 pe_expand_re_matches(const char *string, const pe_re_match_data_t *match_data)
 {
     if (match_data == NULL) {
         return NULL;
     }
     return pcmk__replace_submatches(string, match_data->string,
                                     match_data->pmatch, match_data->nregs);
 }
 
 // LCOV_EXCL_STOP
 // End deprecated API