diff --git a/lib/common/rules.c b/lib/common/rules.c
index 6d8f57d0bc..839bffb8ce 100644
--- a/lib/common/rules.c
+++ b/lib/common/rules.c
@@ -1,1508 +1,1512 @@
 /*
  * 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 <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 condition type corresponding to given condition XML
  *
  * \param[in] condition  Rule condition XML
  *
  * \return Condition type corresponding to \p condition
  */
 enum expression_type
 pcmk__condition_type(const xmlNode *condition)
 {
     const char *name = NULL;
 
     // Expression types based on element name
 
     if (pcmk__xe_is(condition, PCMK_XE_DATE_EXPRESSION)) {
         return pcmk__condition_datetime;
 
     } else if (pcmk__xe_is(condition, PCMK_XE_RSC_EXPRESSION)) {
         return pcmk__condition_resource;
 
     } else if (pcmk__xe_is(condition, PCMK_XE_OP_EXPRESSION)) {
         return pcmk__condition_operation;
 
     } else if (pcmk__xe_is(condition, PCMK_XE_RULE)) {
         return pcmk__condition_rule;
 
     } else if (!pcmk__xe_is(condition, PCMK_XE_EXPRESSION)) {
         return pcmk__condition_unknown;
     }
 
     // Expression types based on node attribute name
 
     name = crm_element_value(condition, PCMK_XA_ATTRIBUTE);
 
     if (pcmk__str_any_of(name, CRM_ATTR_UNAME, CRM_ATTR_KIND, CRM_ATTR_ID,
                          NULL)) {
         return pcmk__condition_location;
     }
 
     return pcmk__condition_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 of parent date expression 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
 
     } else if (pcmk__parse_ll_range(range, &low, &high) != pcmk_rc_ok) {
         // Invalid range
         pcmk__config_err("Treating " PCMK_XE_DATE_EXPRESSION " %s "
                          "as not passing because '%s' is not a valid range "
                          "for " PCMK_XE_DATE_SPEC " attribute %s",
                          id, range, attr);
         rc = pcmk_rc_unpack_error;
 
     } else if ((low != -1) && (value < low)) {
         rc = pcmk_rc_before_range;
 
     } else if ((high != -1) && (value > high)) {
         rc = pcmk_rc_after_range;
     }
 
     crm_trace(PCMK_XE_DATE_EXPRESSION " %s: " PCMK_XE_DATE_SPEC
               " %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_unpack_error if the specification XML is invalid,
  *         \c pcmk_rc_ok if \p now is within the specification's ranges, or
  *         \c pcmk_rc_before_range or \c pcmk_rc_after_range as appropriate)
  */
 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
         pcmk__config_err("Treating " PCMK_XE_DATE_EXPRESSION " %s as not "
                          "passing because " PCMK_XE_DATE_SPEC
                          " subelement has no " PCMK_XA_ID, parent_id);
         return pcmk_rc_unpack_error;
     }
 
     // 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, parent_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;                                                    \
+        int rc = pcmk__add_time_from_xml(*end, component, duration);        \
+        if (rc != pcmk_rc_ok) {                                             \
+            pcmk__config_err("Treating " PCMK_XE_DATE_EXPRESSION " %s "     \
+                             "as not passing because " PCMK_XE_DURATION     \
+                             " %s attribute %s is invalid",                 \
+                             parent_id, id,                                 \
+                             pcmk__time_component_attr(component));         \
+            crm_time_free(*end);                                            \
+            *end = NULL;                                                    \
+            return 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";
+        pcmk__config_err("Treating " PCMK_XE_DATE_EXPRESSION " %s "
+                         "as not passing because " PCMK_XE_DURATION
+                         " subelement has no " PCMK_XA_ID, parent_id);
+        return pcmk_rc_unpack_error;
     }
 
     *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;
+    return pcmk_rc_ok;
 }
 
 /*!
  * \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 = pcmk__xe_first_child(date_expression,
                                                  PCMK_XE_DURATION, NULL, NULL);
 
         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);
+            int rc = pcmk__unpack_duration(duration, start, &end);
+
+            if (rc != pcmk_rc_ok) {
+                pcmk__config_err("Treating " PCMK_XE_DATE_EXPRESSION
+                                 " %s as not passing because duration "
+                                 "is invalid", id);
+                crm_time_free(start);
+                return rc;
+            }
         }
     }
 
     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 = pcmk__xe_first_child(date_expression,
                                                   PCMK_XE_DATE_SPEC, NULL,
                                                   NULL);
 
         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) || pcmk__str_empty(match)) {
         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 = pcmk__assert_alloc(nbytes, sizeof(char));
 
     // 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 strings according to a given type
  *
  * \param[in] value1  String with first value to compare
  * \param[in] value2  String with second value to compare
  * \param[in] type    How to interpret the values
  *
  * \return Standard comparison result (a negative integer if \p value1 is
  *         lesser, 0 if the values are equal, and a positive integer if
  *         \p value1 is greater)
  */
 int
 pcmk__cmp_by_type(const char *value1, const char *value2, enum pcmk__type type)
 {
     //  NULL compares as less than non-NULL
     if (value2 == NULL) {
         return (value1 == NULL)? 0 : 1;
     }
     if (value1 == NULL) {
         return -1;
     }
 
     switch (type) {
         case pcmk__type_string:
             return strcasecmp(value1, value2);
 
         case pcmk__type_integer:
             {
                 long long integer1;
                 long long integer2;
 
                 if ((pcmk__scan_ll(value1, &integer1, 0LL) != pcmk_rc_ok)
                     || (pcmk__scan_ll(value2, &integer2, 0LL) != pcmk_rc_ok)) {
                     crm_warn("Comparing '%s' and '%s' as strings because "
                              "invalid as integers", value1, value2);
                     return strcasecmp(value1, value2);
                 }
                 return (integer1 < integer2)? -1 : (integer1 > integer2)? 1 : 0;
             }
             break;
 
         case pcmk__type_number:
             {
                 double num1;
                 double num2;
 
                 if ((pcmk__scan_double(value1, &num1, NULL, NULL) != pcmk_rc_ok)
                     || (pcmk__scan_double(value2, &num2, NULL,
                                           NULL) != pcmk_rc_ok)) {
                     crm_warn("Comparing '%s' and '%s' as strings because invalid as "
                              "numbers", value1, value2);
                     return strcasecmp(value1, value2);
                 }
                 return (num1 < num2)? -1 : (num1 > num2)? 1 : 0;
             }
             break;
 
         case pcmk__type_version:
             return compare_version(value1, value2);
 
         default: // Invalid type
             return 0;
     }
 }
 
 /*!
  * \internal
  * \brief Parse a reference value source from a string
  *
  * \param[in] source  String indicating reference value source
  *
  * \return Reference value source corresponding to \p source
  */
 enum pcmk__reference_source
 pcmk__parse_source(const char *source)
 {
     if (pcmk__str_eq(source, PCMK_VALUE_LITERAL,
                      pcmk__str_casei|pcmk__str_null_matches)) {
         return pcmk__source_literal;
 
     } else if (pcmk__str_eq(source, PCMK_VALUE_PARAM, pcmk__str_casei)) {
         return pcmk__source_instance_attrs;
 
     } else if (pcmk__str_eq(source, PCMK_VALUE_META, pcmk__str_casei)) {
         return pcmk__source_meta_attrs;
 
     } else {
         return pcmk__source_unknown;
     }
 }
 
 /*!
  * \internal
  * \brief Parse a boolean operator from a string
  *
  * \param[in] combine  String indicating boolean operator
  *
  * \return Enumeration value corresponding to \p combine
  */
 enum pcmk__combine
 pcmk__parse_combine(const char *combine)
 {
     if (pcmk__str_eq(combine, PCMK_VALUE_AND,
                      pcmk__str_null_matches|pcmk__str_casei)) {
         return pcmk__combine_and;
 
     } else if (pcmk__str_eq(combine, PCMK_VALUE_OR, pcmk__str_casei)) {
         return pcmk__combine_or;
 
     } else {
         return pcmk__combine_unknown;
     }
 }
 
 /*!
  * \internal
  * \brief Get the result of a node attribute comparison for rule evaluation
  *
  * \param[in] actual      Actual node attribute value
  * \param[in] reference   Node attribute value from rule (ignored for
  *                        \p comparison of \c pcmk__comparison_defined or
  *                        \c pcmk__comparison_undefined)
  * \param[in] type        How to interpret the values
  * \param[in] comparison  How to compare the values
  *
  * \return Standard Pacemaker return code (specifically, \c pcmk_rc_ok if the
  *         comparison passes, and some other value if it does not)
  */
 static int
 evaluate_attr_comparison(const char *actual, const char *reference,
                          enum pcmk__type type, enum pcmk__comparison comparison)
 {
     int cmp = 0;
 
     switch (comparison) {
         case pcmk__comparison_defined:
             return (actual != NULL)? pcmk_rc_ok : pcmk_rc_op_unsatisfied;
 
         case pcmk__comparison_undefined:
             return (actual == NULL)? pcmk_rc_ok : pcmk_rc_op_unsatisfied;
 
         default:
             break;
     }
 
     cmp = pcmk__cmp_by_type(actual, reference, type);
 
     switch (comparison) {
         case pcmk__comparison_eq:
             return (cmp == 0)? pcmk_rc_ok : pcmk_rc_op_unsatisfied;
 
         case pcmk__comparison_ne:
             return (cmp != 0)? pcmk_rc_ok : pcmk_rc_op_unsatisfied;
 
         default:
             break;
     }
 
     if ((actual == NULL) || (reference == NULL)) {
         return pcmk_rc_op_unsatisfied; // Comparison would be meaningless
     }
 
     switch (comparison) {
         case pcmk__comparison_lt:
             return (cmp < 0)? pcmk_rc_ok : pcmk_rc_after_range;
 
         case pcmk__comparison_lte:
             return (cmp <= 0)? pcmk_rc_ok : pcmk_rc_after_range;
 
         case pcmk__comparison_gt:
             return (cmp > 0)? pcmk_rc_ok : pcmk_rc_before_range;
 
         case pcmk__comparison_gte:
             return (cmp >= 0)? pcmk_rc_ok : pcmk_rc_before_range;
 
         default: // Not possible with schema validation enabled
             return pcmk_rc_op_unsatisfied;
     }
 }
 
 /*!
  * \internal
  * \brief Get a reference value from a configured source
  *
  * \param[in] value       Value given in rule expression
  * \param[in] source      Reference value source
  * \param[in] rule_input  Values used to evaluate rule criteria
  */
 static const char *
 value_from_source(const char *value, enum pcmk__reference_source source,
                   const pcmk_rule_input_t *rule_input)
 {
     GHashTable *table = NULL;
 
     if (pcmk__str_empty(value)) {
         /* @COMPAT When we can break backward compatibility, drop this block so
          * empty strings are treated as such (there should never be an empty
          * string as an instance attribute or meta-attribute name, so those will
          * get NULL anyway, but it could matter for literal comparisons)
          */
         return NULL;
     }
 
     switch (source) {
         case pcmk__source_literal:
             return value;
 
         case pcmk__source_instance_attrs:
             table = rule_input->rsc_params;
             break;
 
         case pcmk__source_meta_attrs:
             table = rule_input->rsc_meta;
             break;
 
         default:
             return NULL; // Not possible
     }
 
     if (table == NULL) {
         return NULL;
     }
     return (const char *) g_hash_table_lookup(table, value);
 }
 
 /*!
  * \internal
  * \brief Evaluate a node attribute rule expression
  *
  * \param[in] expression  XML of a rule's PCMK_XE_EXPRESSION subelement
  * \param[in] rule_input  Values used to evaluate rule criteria
  *
  * \return Standard Pacemaker return code (\c pcmk_rc_ok if the expression
  *         passes, some other value if it does not)
  */
 int
 pcmk__evaluate_attr_expression(const xmlNode *expression,
                                const pcmk_rule_input_t *rule_input)
 {
     const char *id = NULL;
     const char *op = NULL;
     const char *attr = NULL;
     const char *type_s = NULL;
     const char *value = NULL;
     const char *actual = NULL;
     const char *source_s = NULL;
     const char *reference = NULL;
     char *expanded_attr = NULL;
     int rc = pcmk_rc_ok;
 
     enum pcmk__type type = pcmk__type_unknown;
     enum pcmk__reference_source source = pcmk__source_unknown;
     enum pcmk__comparison comparison = pcmk__comparison_unknown;
 
     if ((expression == NULL) || (rule_input == NULL)) {
         return EINVAL;
     }
 
     // Get expression ID (for logging)
     id = pcmk__xe_id(expression);
     if (pcmk__str_empty(id)) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * fail the expression
          */
         pcmk__config_warn(PCMK_XE_EXPRESSION " element has no " PCMK_XA_ID);
         id = "without ID"; // for logging
     }
 
     /* Get name of node attribute to compare (expanding any %0-%9 to
      * regular expression submatches)
      */
     attr = crm_element_value(expression, PCMK_XA_ATTRIBUTE);
     if (attr == NULL) {
         pcmk__config_err("Treating " PCMK_XE_EXPRESSION " %s as not passing "
                          "because " PCMK_XA_ATTRIBUTE " was not specified", id);
         return pcmk_rc_unpack_error;
     }
     expanded_attr = pcmk__replace_submatches(attr, rule_input->rsc_id,
                                              rule_input->rsc_id_submatches,
                                              rule_input->rsc_id_nmatches);
     if (expanded_attr != NULL) {
         attr = expanded_attr;
     }
 
     // Get and validate operation
     op = crm_element_value(expression, 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 " PCMK_XE_EXPRESSION " %s as not "
                              "passing because it has no " PCMK_XA_OPERATION,
                              id);
         } else {
             pcmk__config_err("Treating " PCMK_XE_EXPRESSION " %s as not "
                              "passing because '%s' is not a valid "
                              PCMK_XA_OPERATION, id, op);
         }
         rc = pcmk_rc_unpack_error;
         goto done;
     }
 
     // How reference value is obtained (literal, resource meta-attribute, etc.)
     source_s = crm_element_value(expression, PCMK_XA_VALUE_SOURCE);
     source = pcmk__parse_source(source_s);
     if (source == pcmk__source_unknown) {
         // Not possible with schema validation enabled
         // @COMPAT Fail expression once we can break backward compatibility
         pcmk__config_warn("Expression %s has invalid " PCMK_XA_VALUE_SOURCE
                           " value '%s', using default "
                           "('" PCMK_VALUE_LITERAL "')", id, source_s);
         source = pcmk__source_literal;
     }
 
     // Get and validate reference value
     value = crm_element_value(expression, PCMK_XA_VALUE);
     switch (comparison) {
         case pcmk__comparison_defined:
         case pcmk__comparison_undefined:
             if (value != NULL) {
                 pcmk__config_warn("Ignoring " PCMK_XA_VALUE " in "
                                   PCMK_XE_EXPRESSION " %s because it is unused "
                                   "when " PCMK_XA_BOOLEAN_OP " is %s", id, op);
             }
             break;
 
         default:
             if (value == NULL) {
                 pcmk__config_warn(PCMK_XE_EXPRESSION " %s has no "
                                   PCMK_XA_VALUE, id);
             }
             break;
     }
     reference = value_from_source(value, source, rule_input);
 
     // Get actual value of node attribute
     if (rule_input->node_attrs != NULL) {
         actual = g_hash_table_lookup(rule_input->node_attrs, attr);
     }
 
     // Get and validate value type (after expanding reference value)
     type_s = crm_element_value(expression, PCMK_XA_TYPE);
     type = pcmk__parse_type(type_s, comparison, actual, reference);
     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 " PCMK_XE_EXPRESSION " %s because '%s' "
                           "is not a valid type", id, type);
     }
 
     rc = evaluate_attr_comparison(actual, reference, type, comparison);
     switch (comparison) {
         case pcmk__comparison_defined:
         case pcmk__comparison_undefined:
             crm_trace(PCMK_XE_EXPRESSION " %s result: %s (for attribute %s %s)",
                       id, pcmk_rc_str(rc), attr, op);
             break;
 
         default:
             crm_trace(PCMK_XE_EXPRESSION " %s result: "
                       "%s (attribute %s %s '%s' via %s source as %s type)",
                       id, pcmk_rc_str(rc), attr, op, pcmk__s(reference, ""),
                       pcmk__s(source_s, "default"), pcmk__s(type_s, "default"));
             break;
     }
 
 done:
     free(expanded_attr);
     return rc;
 }
 
 /*!
  * \internal
  * \brief Evaluate a resource rule expression
  *
  * \param[in] rsc_expression  XML of rule's \c PCMK_XE_RSC_EXPRESSION subelement
  * \param[in] rule_input      Values used to evaluate rule criteria
  *
  * \return Standard Pacemaker return code (\c pcmk_rc_ok if the expression
  *         passes, some other value if it does not)
  */
 int
 pcmk__evaluate_rsc_expression(const xmlNode *rsc_expression,
                               const pcmk_rule_input_t *rule_input)
 {
     const char *id = NULL;
     const char *standard = NULL;
     const char *provider = NULL;
     const char *type = NULL;
 
     if ((rsc_expression == NULL) || (rule_input == NULL)) {
         return EINVAL;
     }
 
     // Validate XML ID
     id = pcmk__xe_id(rsc_expression);
     if (pcmk__str_empty(id)) {
         // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * fail the expression
          */
         pcmk__config_warn(PCMK_XE_RSC_EXPRESSION " has no " PCMK_XA_ID);
         id = "without ID"; // for logging
     }
 
     // Compare resource standard
     standard = crm_element_value(rsc_expression, PCMK_XA_CLASS);
     if ((standard != NULL)
         && !pcmk__str_eq(standard, rule_input->rsc_standard, pcmk__str_none)) {
         crm_trace(PCMK_XE_RSC_EXPRESSION " %s is unsatisfied because "
                   "actual standard '%s' doesn't match '%s'",
                   id, pcmk__s(rule_input->rsc_standard, ""), standard);
         return pcmk_rc_op_unsatisfied;
     }
 
     // Compare resource provider
     provider = crm_element_value(rsc_expression, PCMK_XA_PROVIDER);
     if ((provider != NULL)
         && !pcmk__str_eq(provider, rule_input->rsc_provider, pcmk__str_none)) {
         crm_trace(PCMK_XE_RSC_EXPRESSION " %s is unsatisfied because "
                   "actual provider '%s' doesn't match '%s'",
                   id, pcmk__s(rule_input->rsc_provider, ""), provider);
         return pcmk_rc_op_unsatisfied;
     }
 
     // Compare resource agent type
     type = crm_element_value(rsc_expression, PCMK_XA_TYPE);
     if ((type != NULL)
         && !pcmk__str_eq(type, rule_input->rsc_agent, pcmk__str_none)) {
         crm_trace(PCMK_XE_RSC_EXPRESSION " %s is unsatisfied because "
                   "actual agent '%s' doesn't match '%s'",
                   id, pcmk__s(rule_input->rsc_agent, ""), type);
         return pcmk_rc_op_unsatisfied;
     }
 
     crm_trace(PCMK_XE_RSC_EXPRESSION " %s is satisfied by %s%s%s:%s",
               id, pcmk__s(standard, ""),
               ((provider == NULL)? "" : ":"), pcmk__s(provider, ""),
               pcmk__s(type, ""));
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Evaluate an operation rule expression
  *
  * \param[in] op_expression  XML of a rule's \c PCMK_XE_OP_EXPRESSION subelement
  * \param[in] rule_input     Values used to evaluate rule criteria
  *
  * \return Standard Pacemaker return code (\c pcmk_rc_ok if the expression
  *         is satisfied, some other value if it is not)
  */
 int
 pcmk__evaluate_op_expression(const xmlNode *op_expression,
                              const pcmk_rule_input_t *rule_input)
 {
     const char *id = NULL;
     const char *name = NULL;
     const char *interval_s = NULL;
     guint interval_ms = 0U;
 
     if ((op_expression == NULL) || (rule_input == NULL)) {
         return EINVAL;
     }
 
     // Get operation expression ID (for logging)
     id = pcmk__xe_id(op_expression);
     if (pcmk__str_empty(id)) { // Not possible with schema validation enabled
         /* @COMPAT When we can break behavioral backward compatibility,
          * return pcmk_rc_op_unsatisfied
          */
         pcmk__config_warn(PCMK_XE_OP_EXPRESSION " element has no " PCMK_XA_ID);
         id = "without ID"; // for logging
     }
 
     // Validate operation name
     name = crm_element_value(op_expression, PCMK_XA_NAME);
     if (name == NULL) { // Not possible with schema validation enabled
         pcmk__config_warn("Treating " PCMK_XE_OP_EXPRESSION " %s as not "
                           "passing because it has no " PCMK_XA_NAME, id);
         return pcmk_rc_unpack_error;
     }
 
     // Validate operation interval
     interval_s = crm_element_value(op_expression, PCMK_META_INTERVAL);
     if (pcmk_parse_interval_spec(interval_s, &interval_ms) != pcmk_rc_ok) {
         pcmk__config_warn("Treating " PCMK_XE_OP_EXPRESSION " %s as not "
                           "passing because '%s' is not a valid interval",
                           id, interval_s);
         return pcmk_rc_unpack_error;
     }
 
     // Compare operation name
     if (!pcmk__str_eq(name, rule_input->op_name, pcmk__str_none)) {
         crm_trace(PCMK_XE_OP_EXPRESSION " %s is unsatisfied because "
                   "actual name '%s' doesn't match '%s'",
                   id, pcmk__s(rule_input->op_name, ""), name);
         return pcmk_rc_op_unsatisfied;
     }
 
     // Compare operation interval (unspecified interval matches all)
     if ((interval_s != NULL) && (interval_ms != rule_input->op_interval_ms)) {
         crm_trace(PCMK_XE_OP_EXPRESSION " %s is unsatisfied because "
                   "actual interval %s doesn't match %s",
                   id, pcmk__readable_interval(rule_input->op_interval_ms),
                   pcmk__readable_interval(interval_ms));
         return pcmk_rc_op_unsatisfied;
     }
 
     crm_trace(PCMK_XE_OP_EXPRESSION " %s is satisfied (name %s, interval %s)",
               id, name, pcmk__readable_interval(rule_input->op_interval_ms));
     return pcmk_rc_ok;
 }
 
 /*!
  * \internal
  * \brief Evaluate a rule condition
  *
  * \param[in,out] condition    XML containing a rule condition (a subrule, or an
  *                             expression of any type)
  * \param[in]     rule_input   Values used to evaluate rule criteria
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return Standard Pacemaker return code (\c pcmk_rc_ok if the condition
  *         passes, some other value if it does not)
  */
 int
 pcmk__evaluate_condition(xmlNode *condition,
                          const pcmk_rule_input_t *rule_input,
                          crm_time_t *next_change)
 {
 
     if ((condition == NULL) || (rule_input == NULL)) {
         return EINVAL;
     }
 
     switch (pcmk__condition_type(condition)) {
         case pcmk__condition_rule:
             return pcmk_evaluate_rule(condition, rule_input, next_change);
 
         case pcmk__condition_attribute:
         case pcmk__condition_location:
             return pcmk__evaluate_attr_expression(condition, rule_input);
 
         case pcmk__condition_datetime:
             {
                 int rc = pcmk__evaluate_date_expression(condition,
                                                         rule_input->now,
                                                         next_change);
 
                 return (rc == pcmk_rc_within_range)? pcmk_rc_ok : rc;
             }
 
         case pcmk__condition_resource:
             return pcmk__evaluate_rsc_expression(condition, rule_input);
 
         case pcmk__condition_operation:
             return pcmk__evaluate_op_expression(condition, rule_input);
 
         default: // Not possible with schema validation enabled
             pcmk__config_err("Treating rule condition %s as not passing "
                              "because %s is not a valid condition type",
                              pcmk__s(pcmk__xe_id(condition), "without ID"),
                              (const char *) condition->name);
             return pcmk_rc_unpack_error;
     }
 }
 
 /*!
  * \brief Evaluate a single rule, including all its conditions
  *
  * \param[in,out] rule         XML containing a rule definition or its id-ref
  * \param[in]     rule_input   Values used to evaluate rule criteria
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return Standard Pacemaker return code (\c pcmk_rc_ok if the rule is
  *         satisfied, some other value if it is not)
  */
 int
 pcmk_evaluate_rule(xmlNode *rule, const pcmk_rule_input_t *rule_input,
                    crm_time_t *next_change)
 {
     bool empty = true;
     int rc = pcmk_rc_ok;
     const char *id = NULL;
     const char *value = NULL;
     enum pcmk__combine combine = pcmk__combine_unknown;
 
     if ((rule == NULL) || (rule_input == NULL)) {
         return EINVAL;
     }
 
     rule = pcmk__xe_resolve_idref(rule, NULL);
     if (rule == NULL) {
         // Not possible with schema validation enabled; message already logged
         return pcmk_rc_unpack_error;
     }
 
     // Validate XML ID
     id = pcmk__xe_id(rule);
     if (pcmk__str_empty(id)) {
         /* @COMPAT When we can break behavioral backward compatibility,
          * fail the rule
          */
         pcmk__config_warn(PCMK_XE_RULE " has no " PCMK_XA_ID);
         id = "without ID"; // for logging
     }
 
     value = crm_element_value(rule, PCMK_XA_BOOLEAN_OP);
     combine = pcmk__parse_combine(value);
     switch (combine) {
         case pcmk__combine_and:
             // For "and", rc defaults to success (reset on failure below)
             break;
 
         case pcmk__combine_or:
             // For "or", rc defaults to failure (reset on success below)
             rc = pcmk_rc_op_unsatisfied;
             break;
 
         default:
             /* @COMPAT When we can break behavioral backward compatibility,
              * return pcmk_rc_unpack_error
              */
             pcmk__config_warn("Rule %s has invalid " PCMK_XA_BOOLEAN_OP
                               " value '%s', using default '" PCMK_VALUE_AND "'",
                               pcmk__xe_id(rule), value);
             combine = pcmk__combine_and;
             break;
     }
 
     // Evaluate each condition
     for (xmlNode *condition = pcmk__xe_first_child(rule, NULL, NULL, NULL);
          condition != NULL; condition = pcmk__xe_next(condition)) {
 
         empty = false;
         if (pcmk__evaluate_condition(condition, rule_input,
                                      next_change) == pcmk_rc_ok) {
             if (combine == pcmk__combine_or) {
                 rc = pcmk_rc_ok; // Any pass is final for "or"
                 break;
             }
         } else if (combine == pcmk__combine_and) {
             rc = pcmk_rc_op_unsatisfied; // Any failure is final for "and"
             break;
         }
     }
 
     if (empty) { // Not possible with schema validation enabled
         /* @COMPAT Currently, we don't actually ignore "or" rules because
          * rc is initialized to failure above in that case. When we can break
          * backward compatibility, reset rc to pcmk_rc_ok here.
          */
         pcmk__config_warn("Ignoring rule %s because it contains no conditions",
                           id);
     }
 
     crm_trace("Rule %s is %ssatisfied", id, ((rc == pcmk_rc_ok)? "" : "not "));
     return rc;
 }
 
 /*!
  * \internal
  * \brief Evaluate all rules contained within an element
  *
  * \param[in,out] xml          XML element possibly containing rule subelements
  * \param[in]     rule_input   Values used to evaluate rule criteria
  * \param[out]    next_change  If not NULL, set to when evaluation will change
  *
  * \return Standard Pacemaker return code (pcmk_rc_ok if there are no contained
  *         rules or any contained rule passes, otherwise the result of the last
  *         rule)
  * \deprecated On code paths leading to this function, the schema allows
  *             multiple top-level rules only in the deprecated lifetime element
  *             of location constraints. The code also allows multiple top-level
  *             rules when unpacking attribute sets, but this is deprecated and
  *             already prevented by schema validation. This function can be
  *             dropped when support for those is dropped.
  */
 int
 pcmk__evaluate_rules(xmlNode *xml, const pcmk_rule_input_t *rule_input,
                      crm_time_t *next_change)
 {
     // If there are no rules, pass by default
     int rc = pcmk_rc_ok;
     bool have_rule = false;
 
     for (xmlNode *rule = pcmk__xe_first_child(xml, PCMK_XE_RULE, NULL, NULL);
          rule != NULL; rule = pcmk__xe_next_same(rule)) {
 
         if (have_rule) {
             pcmk__warn_once(pcmk__wo_multiple_rules,
                             "Support for multiple top-level rules is "
                             "deprecated (replace with a single rule containing "
                             "the existing rules with " PCMK_XA_BOOLEAN_OP
                             "set to " PCMK_VALUE_OR " instead)");
         } else {
             have_rule = true;
         }
 
         rc = pcmk_evaluate_rule(rule, rule_input, next_change);
         if (rc == pcmk_rc_ok) {
             break;
         }
     }
     return rc;
 }
diff --git a/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c b/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c
index 7bcb005c11..97573c44ad 100644
--- a/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c
+++ b/lib/common/tests/rules/pcmk__evaluate_date_expression_test.c
@@ -1,667 +1,628 @@
 /*
  * 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 <stdio.h>
 #include <glib.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/rules_internal.h>
 #include <crm/common/unittest_internal.h>
 #include "crmcommon_private.h"
 
 /*!
  * \internal
  * \brief Run one test, comparing return value and output argument
  *
  * \param[in] xml            Date expression XML
  * \param[in] now_s          Time to evaluate expression with (as string)
  * \param[in] next_change_s  If this and \p reference_s are not NULL, initialize
  *                           next change time with this time (as string),
  *                           and assert that its value after evaluation is the
  *                           reference
  * \param[in] reference_s    If not NULL, time (as string) that next change
  *                           should be after expression evaluation
  * \param[in] reference_rc   Assert that evaluation result equals this
  */
 static void
 assert_date_expression(const xmlNode *xml, const char *now_s,
                        const char *next_change_s, const char *reference_s,
                        int reference_rc)
 {
     crm_time_t *now = NULL;
     crm_time_t *next_change = NULL;
     bool check_next_change = (next_change_s != NULL) && (reference_s != NULL);
 
     if (check_next_change) {
         next_change = crm_time_new(next_change_s);
     }
 
     now = crm_time_new(now_s);
     assert_int_equal(pcmk__evaluate_date_expression(xml, now, next_change),
                      reference_rc);
     crm_time_free(now);
 
     if (check_next_change) {
         crm_time_t *reference = crm_time_new(reference_s);
 
         assert_int_equal(crm_time_compare(next_change, reference), 0);
         crm_time_free(reference);
         crm_time_free(next_change);
     }
 }
 
 #define EXPR_LT_VALID                                   \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
         PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' "       \
         PCMK_XA_END "='2024-02-01 15:00:00' />"
 
 static void
 null_invalid(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
     crm_time_t *t = crm_time_new("2024-02-01");
 
     assert_int_equal(pcmk__evaluate_date_expression(NULL, NULL, NULL), EINVAL);
     assert_int_equal(pcmk__evaluate_date_expression(xml, NULL, NULL), EINVAL);
     assert_int_equal(pcmk__evaluate_date_expression(NULL, t, NULL), EINVAL);
 
     crm_time_free(t);
     pcmk__xml_free(xml);
 }
 
 static void
 null_next_change_ok(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_within_range);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_ID_MISSING                             \
     "<" PCMK_XE_DATE_EXPRESSION " "                 \
         PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' "   \
         PCMK_XA_END "='2024-02-01 15:00:00' />"
 
 static void
 id_missing(void **state)
 {
     // Currently acceptable
     xmlNodePtr xml = pcmk__xml_parse(EXPR_ID_MISSING);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_within_range);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_OP_INVALID                                 \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
         PCMK_XA_OPERATION "='not-a-choice' />"
 
 static void
 op_invalid(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_OP_INVALID);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_LT_MISSING_END                             \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
         PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' />"
 
 static void
 lt_missing_end(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_MISSING_END);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_LT_INVALID_END                             \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_LT "' "           \
     PCMK_XA_END "='not-a-datetime' />"
 
 static void
 lt_invalid_end(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_INVALID_END);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 static void
 lt_valid(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_LT_VALID);
 
     // Now and next change are both before end
     assert_date_expression(xml, "2023-01-01 05:00:00", "2024-02-01 10:00:00",
                            "2024-02-01 10:00:00", pcmk_rc_within_range);
 
     // Now is before end, next change is after end
     assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 15:00:00", pcmk_rc_within_range);
 
     // Now is equal to end, next change is after end
     assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 20:00:00",
                            "2024-02-01 20:00:00", pcmk_rc_after_range);
 
     // Now and next change are both after end
     assert_date_expression(xml, "2024-03-01 12:00:00", "2024-02-01 20:00:00",
                            "2024-02-01 20:00:00", pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_GT_MISSING_START                           \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' />"
 
 static void
 gt_missing_start(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_MISSING_START);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_GT_INVALID_START                           \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' "           \
     PCMK_XA_START "='not-a-datetime' />"
 
 static void
 gt_invalid_start(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_INVALID_START);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_GT_VALID                                   \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_GT "' "           \
     PCMK_XA_START "='2024-02-01 12:00:00' />"
 
 static void
 gt_valid(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_GT_VALID);
 
     // Now and next change are both before start
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_before_range);
 
     // Now is before start, next change is after start
     assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 12:00:01", pcmk_rc_before_range);
 
     // Now is equal to start, next change is after start
     assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
                            "2024-02-01 12:00:01", pcmk_rc_before_range);
 
     // Now is one second after start, next change is after start
     assert_date_expression(xml, "2024-02-01 12:00:01", "2024-02-01 18:00:00",
                            "2024-02-01 18:00:00", pcmk_rc_within_range);
 
     // t is after start, next change is after start
     assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
                            "2024-04-04 04:04:04", pcmk_rc_within_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_MISSING                              \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' />"
 
 static void
 range_missing(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_MISSING);
     crm_time_t *t = crm_time_new("2024-01-01");
 
     assert_int_equal(pcmk__evaluate_date_expression(xml, t, NULL),
                      pcmk_rc_undetermined);
 
     crm_time_free(t);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_INVALID_START_INVALID_END            \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='not-a-date' "                      \
     PCMK_XA_END "='not-a-date' />"
 
 static void
 range_invalid_start_invalid_end(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_INVALID_END);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_INVALID_START_ONLY                   \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='not-a-date' />"
 
 static void
 range_invalid_start_only(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_ONLY);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_ONLY                     \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00' />"
 
 static void
 range_valid_start_only(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_ONLY);
 
     // Now and next change are before start
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_before_range);
 
     // Now is before start, next change is after start
     assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 12:00:00", pcmk_rc_before_range);
 
     // Now is equal to start, next change is after start
     assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
                            "2024-02-01 18:00:00", pcmk_rc_within_range);
 
     // Now and next change are after start
     assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
                            "2024-04-04 04:04:04", pcmk_rc_within_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_INVALID_END_ONLY                   \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_END "='not-a-date' />"
 
 static void
 range_invalid_end_only(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_END_ONLY);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_END_ONLY                     \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_END "='2024-02-01 15:00:00' />"
 
 static void
 range_valid_end_only(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_END_ONLY);
 
     // Now and next change are before end
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_within_range);
 
     // Now is before end, next change is after end
     assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now is equal to end, next change is after end
     assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 18:00:00",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now and next change are after end
     assert_date_expression(xml, "2024-02-01 15:00:01", "2024-04-04 04:04:04",
                            "2024-04-04 04:04:04", pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_INVALID_END              \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00' "             \
     PCMK_XA_END "='not-a-date' />"
 
 static void
 range_valid_start_invalid_end(void **state)
 {
     // Currently treated same as start without end
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_INVALID_END);
 
     // Now and next change are before start
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_before_range);
 
     // Now is before start, next change is after start
     assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 12:00:00", pcmk_rc_before_range);
 
     // Now is equal to start, next change is after start
     assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 18:00:00",
                            "2024-02-01 18:00:00", pcmk_rc_within_range);
 
     // Now and next change are after start
     assert_date_expression(xml, "2024-03-01 05:03:11", "2024-04-04 04:04:04",
                            "2024-04-04 04:04:04", pcmk_rc_within_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_INVALID_START_VALID_END              \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='not-a-date' "                      \
     PCMK_XA_END "='2024-02-01 15:00:00' />"
 
 static void
 range_invalid_start_valid_end(void **state)
 {
     // Currently treated same as end without start
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_INVALID_START_VALID_END);
 
     // Now and next change are before end
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_within_range);
 
     // Now is before end, next change is after end
     assert_date_expression(xml, "2024-02-01 14:59:59", "2024-02-01 18:00:00",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now is equal to end, next change is after end
     assert_date_expression(xml, "2024-02-01 15:00:00", "2024-02-01 18:00:00",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now and next change are after end
     assert_date_expression(xml, "2024-02-01 15:00:01", "2024-04-04 04:04:04",
                            "2024-04-04 04:04:04", pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_VALID_END                \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00' "             \
     PCMK_XA_END "='2024-02-01 15:00:00' />"
 
 static void
 range_valid_start_valid_end(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_VALID_END);
 
     // Now and next change are before start
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_before_range);
 
     // Now is before start, next change is between start and end
     assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
                            "2024-02-01 12:00:00", pcmk_rc_before_range);
 
     // Now is equal to start, next change is between start and end
     assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
                            "2024-02-01 14:30:00", pcmk_rc_within_range);
 
     // Now is between start and end, next change is after end
     assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now is equal to end, next change is after end
     assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now and next change are after end
     assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
                            "2028-04-04 04:04:04", pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_INVALID_DURATION         \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00'>"             \
     "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' "         \
     PCMK_XA_HOURS "='not-a-number' />"                  \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 range_valid_start_invalid_duration(void **state)
 {
-    // Currently treated same as end equals start
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_INVALID_DURATION);
 
-    // Now and next change are before start
-    assert_date_expression(xml, "2024-02-01 04:30:05", "2024-01-01 11:00:00",
-                           "2024-01-01 11:00:00", pcmk_rc_before_range);
-
-    // Now is before start, next change is after start
-    assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 18:00:00",
-                           "2024-02-01 12:00:00", pcmk_rc_before_range);
-
-    // Now is equal to start, next change is after start
-    assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
-                           "2024-02-01 12:00:01", pcmk_rc_within_range);
-
-    // Now and next change are after start
-    assert_date_expression(xml, "2024-02-01 12:00:01", "2024-02-01 14:30:00",
-                           "2024-02-01 14:30:00", pcmk_rc_after_range);
-
+    assert_date_expression(xml, "2024-02-01 04:30:05", NULL, NULL,
+                           pcmk_rc_unpack_error);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_VALID_DURATION           \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00'>"             \
     "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' "         \
     PCMK_XA_HOURS "='3' />"                             \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 range_valid_start_valid_duration(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_VALID_DURATION);
 
     // Now and next change are before start
     assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
                            "2024-01-01 11:00:00", pcmk_rc_before_range);
 
     // Now is before start, next change is between start and end
     assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
                            "2024-02-01 12:00:00", pcmk_rc_before_range);
 
     // Now is equal to start, next change is between start and end
     assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
                            "2024-02-01 14:30:00", pcmk_rc_within_range);
 
     // Now is between start and end, next change is after end
     assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now is equal to end, next change is after end
     assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
                            "2024-02-01 15:00:01", pcmk_rc_within_range);
 
     // Now and next change are after end
     assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
                            "2028-04-04 04:04:04", pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_RANGE_VALID_START_DURATION_MISSING_ID      \
-    "<" PCMK_XE_DATE_EXPRESSION " "                     \
+    "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='d' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_IN_RANGE "' "     \
     PCMK_XA_START "='2024-02-01 12:00:00'>"             \
-    "<" PCMK_XE_DURATION " " PCMK_XA_ID "='d' "         \
-    PCMK_XA_HOURS "='3' />"                             \
+    "<" PCMK_XE_DURATION " " PCMK_XA_HOURS "='3' />"    \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 range_valid_start_duration_missing_id(void **state)
 {
-    // Currently acceptable
     xmlNodePtr xml = NULL;
 
     xml = pcmk__xml_parse(EXPR_RANGE_VALID_START_DURATION_MISSING_ID);
 
-    // Now and next change are before start
-    assert_date_expression(xml, "2024-01-01 04:30:05", "2024-01-01 11:00:00",
-                           "2024-01-01 11:00:00", pcmk_rc_before_range);
-
-    // Now is before start, next change is between start and end
-    assert_date_expression(xml, "2024-02-01 11:59:59", "2024-02-01 14:00:00",
-                           "2024-02-01 12:00:00", pcmk_rc_before_range);
-
-    // Now is equal to start, next change is between start and end
-    assert_date_expression(xml, "2024-02-01 12:00:00", "2024-02-01 14:30:00",
-                           "2024-02-01 14:30:00", pcmk_rc_within_range);
-
-    // Now is between start and end, next change is after end
-    assert_date_expression(xml, "2024-02-01 14:03:11", "2024-04-04 04:04:04",
-                           "2024-02-01 15:00:01", pcmk_rc_within_range);
-
-    // Now is equal to end, next change is after end
-    assert_date_expression(xml, "2024-02-01 15:00:00", "2028-04-04 04:04:04",
-                           "2024-02-01 15:00:01", pcmk_rc_within_range);
-
-    // Now and next change are after end
-    assert_date_expression(xml, "2024-02-01 15:00:01", "2028-04-04 04:04:04",
-                           "2028-04-04 04:04:04", pcmk_rc_after_range);
-
+    assert_date_expression(xml, "2024-02-01 04:30:05", NULL, NULL,
+                           pcmk_rc_unpack_error);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_SPEC_MISSING                               \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "' />"
 
 static void
 spec_missing(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_MISSING);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_undetermined);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_SPEC_INVALID                               \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>"    \
     "<" PCMK_XE_DATE_SPEC " " PCMK_XA_ID "='s' "        \
     PCMK_XA_MONTHS "='not-a-number'/>"                  \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 spec_invalid(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_INVALID);
 
     assert_date_expression(xml, "2024-01-01", NULL, NULL, pcmk_rc_unpack_error);
     pcmk__xml_free(xml);
 }
 
 #define EXPR_SPEC_VALID                                 \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>"    \
     "<" PCMK_XE_DATE_SPEC " " PCMK_XA_ID "='s' "        \
     PCMK_XA_MONTHS "='2'/>"                             \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 spec_valid(void **state)
 {
     // date_spec does not currently support next_change
     xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_VALID);
 
     // Now is just before spec start
     assert_date_expression(xml, "2024-01-01 23:59:59", NULL, NULL,
                            pcmk_rc_before_range);
 
     // Now matches spec start
     assert_date_expression(xml, "2024-02-01 00:00:00", NULL, NULL, pcmk_rc_ok);
 
     // Now is within spec range
     assert_date_expression(xml, "2024-02-22 22:22:22", NULL, NULL, pcmk_rc_ok);
 
     // Now matches spec end
     assert_date_expression(xml, "2024-02-29 23:59:59", NULL, NULL, pcmk_rc_ok);
 
     // Now is just past spec end
     assert_date_expression(xml, "2024-03-01 00:00:00", NULL, NULL,
                            pcmk_rc_after_range);
 
     pcmk__xml_free(xml);
 }
 
 #define EXPR_SPEC_MISSING_ID                            \
     "<" PCMK_XE_DATE_EXPRESSION " " PCMK_XA_ID "='e' "  \
     PCMK_XA_OPERATION "='" PCMK_VALUE_DATE_SPEC "'>"    \
     "<" PCMK_XE_DATE_SPEC " "                           \
     PCMK_XA_MONTHS "='2'/>"                             \
     "</" PCMK_XE_DATE_EXPRESSION ">"
 
 static void
 spec_missing_id(void **state)
 {
     xmlNodePtr xml = pcmk__xml_parse(EXPR_SPEC_MISSING_ID);
 
     assert_date_expression(xml, "2024-01-01 23:59:59", NULL, NULL,
                            pcmk_rc_unpack_error);
     pcmk__xml_free(xml);
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(null_invalid),
                 cmocka_unit_test(null_next_change_ok),
                 cmocka_unit_test(id_missing),
                 cmocka_unit_test(op_invalid),
                 cmocka_unit_test(lt_missing_end),
                 cmocka_unit_test(lt_invalid_end),
                 cmocka_unit_test(lt_valid),
                 cmocka_unit_test(gt_missing_start),
                 cmocka_unit_test(gt_invalid_start),
                 cmocka_unit_test(gt_valid),
                 cmocka_unit_test(range_missing),
                 cmocka_unit_test(range_invalid_start_invalid_end),
                 cmocka_unit_test(range_invalid_start_only),
                 cmocka_unit_test(range_valid_start_only),
                 cmocka_unit_test(range_invalid_end_only),
                 cmocka_unit_test(range_valid_end_only),
                 cmocka_unit_test(range_valid_start_invalid_end),
                 cmocka_unit_test(range_invalid_start_valid_end),
                 cmocka_unit_test(range_valid_start_valid_end),
                 cmocka_unit_test(range_valid_start_invalid_duration),
                 cmocka_unit_test(range_valid_start_valid_duration),
                 cmocka_unit_test(range_valid_start_duration_missing_id),
                 cmocka_unit_test(spec_missing),
                 cmocka_unit_test(spec_invalid),
                 cmocka_unit_test(spec_valid),
                 cmocka_unit_test(spec_missing_id))
diff --git a/lib/common/tests/rules/pcmk__unpack_duration_test.c b/lib/common/tests/rules/pcmk__unpack_duration_test.c
index 01be5846b1..2eba68e24e 100644
--- a/lib/common/tests/rules/pcmk__unpack_duration_test.c
+++ b/lib/common/tests/rules/pcmk__unpack_duration_test.c
@@ -1,120 +1,115 @@
 /*
  * 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 <glib.h>
 
 #include <crm/common/unittest_internal.h>
 
 #include <crm/common/iso8601.h>
 #include <crm/common/xml.h>
 #include "../../crmcommon_private.h"
 
 #define MONTHS_TO_SECONDS "months=\"2\" weeks=\"3\" days=\"-1\" "           \
                           "hours=\"1\" minutes=\"1\" seconds=\"1\" />"
 
 #define ALL_VALID "<duration id=\"duration1\" years=\"1\" " MONTHS_TO_SECONDS
 
 #define NO_ID     "<duration years=\"1\" " MONTHS_TO_SECONDS
 
 #define YEARS_INVALID "<duration id=\"duration1\" years=\"not-a-number\" "  \
                       MONTHS_TO_SECONDS
 
 static void
 null_invalid(void **state)
 {
     xmlNode *duration = pcmk__xml_parse(ALL_VALID);
     crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
     crm_time_t *end = NULL;
 
     assert_int_equal(pcmk__unpack_duration(NULL, NULL, NULL), EINVAL);
     assert_int_equal(pcmk__unpack_duration(duration, NULL, NULL), EINVAL);
     assert_int_equal(pcmk__unpack_duration(duration, start, NULL), EINVAL);
     assert_int_equal(pcmk__unpack_duration(duration, NULL, &end), EINVAL);
     assert_int_equal(pcmk__unpack_duration(NULL, start, NULL), EINVAL);
     assert_int_equal(pcmk__unpack_duration(NULL, start, &end), EINVAL);
     assert_int_equal(pcmk__unpack_duration(NULL, NULL, &end), EINVAL);
 
     crm_time_free(start);
     pcmk__xml_free(duration);
 }
 
 static void
 nonnull_end_invalid(void **state)
 {
     xmlNode *duration = pcmk__xml_parse(ALL_VALID);
     crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
     crm_time_t *end = crm_time_new("2024-01-01 15:00:01");
 
     assert_int_equal(pcmk__unpack_duration(duration, start, &end), EINVAL);
 
     crm_time_free(start);
     crm_time_free(end);
     pcmk__xml_free(duration);
 }
 
 static void
 no_id(void **state)
 {
     xmlNode *duration = pcmk__xml_parse(NO_ID);
     crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
     crm_time_t *end = NULL;
-    crm_time_t *reference = crm_time_new("2025-03-21 16:01:01");
 
-    assert_int_equal(pcmk__unpack_duration(duration, start, &end), pcmk_rc_ok);
-    assert_int_equal(crm_time_compare(end, reference), 0);
+    assert_int_equal(pcmk__unpack_duration(duration, start, &end),
+                     pcmk_rc_unpack_error);
+    assert_null(end);
 
     crm_time_free(start);
-    crm_time_free(end);
-    crm_time_free(reference);
     pcmk__xml_free(duration);
 }
 
 static void
 years_invalid(void **state)
 {
     xmlNode *duration = pcmk__xml_parse(YEARS_INVALID);
     crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
     crm_time_t *end = NULL;
-    crm_time_t *reference = crm_time_new("2024-03-21 16:01:01");
 
     assert_int_equal(pcmk__unpack_duration(duration, start, &end),
                      pcmk_rc_unpack_error);
-    assert_int_equal(crm_time_compare(end, reference), 0);
+    assert_null(end);
 
     crm_time_free(start);
-    crm_time_free(end);
-    crm_time_free(reference);
     pcmk__xml_free(duration);
 }
 
 static void
 all_valid(void **state)
 {
     xmlNode *duration = pcmk__xml_parse(ALL_VALID);
     crm_time_t *start = crm_time_new("2024-01-01 15:00:00");
     crm_time_t *end = NULL;
     crm_time_t *reference = crm_time_new("2025-03-21 16:01:01");
 
     assert_int_equal(pcmk__unpack_duration(duration, start, &end), pcmk_rc_ok);
     assert_int_equal(crm_time_compare(end, reference), 0);
 
     crm_time_free(start);
     crm_time_free(end);
     crm_time_free(reference);
     pcmk__xml_free(duration);
 }
 
 PCMK__UNIT_TEST(pcmk__xml_test_setup_group, pcmk__xml_test_teardown_group,
                 cmocka_unit_test(null_invalid),
                 cmocka_unit_test(nonnull_end_invalid),
                 cmocka_unit_test(no_id),
                 cmocka_unit_test(years_invalid),
                 cmocka_unit_test(all_valid))
diff --git a/lib/pacemaker/pcmk_rule.c b/lib/pacemaker/pcmk_rule.c
index ed8c290c58..4fcb922724 100644
--- a/lib/pacemaker/pcmk_rule.c
+++ b/lib/pacemaker/pcmk_rule.c
@@ -1,214 +1,215 @@
 /*
  * Copyright 2022-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/cib/internal.h>
 #include <crm/common/cib.h>
 #include <crm/common/iso8601.h>
 #include <crm/common/xml.h>
 #include <crm/pengine/internal.h>
 #include <crm/pengine/rules_internal.h>
 #include <pacemaker-internal.h>
 
 #include "libpacemaker_private.h"
 
 #define XPATH_NODE_RULE "//" PCMK_XE_RULE "[@" PCMK_XA_ID "='%s']"
 
 /*!
  * \internal
  * \brief Check whether a given rule is in effect
  *
  * \param[in]     scheduler  Scheduler data
  * \param[in]     rule_id    The ID of the rule to check
  * \param[out]    error      Where to store a rule evaluation error message
  *
  * \return Standard Pacemaker return code
  */
 static int
 eval_rule(pcmk_scheduler_t *scheduler, const char *rule_id, const char **error)
 {
     xmlNodePtr cib_constraints = NULL;
     xmlNodePtr match = NULL;
     xmlXPathObjectPtr xpath_obj = NULL;
     char *xpath = NULL;
     int rc = pcmk_rc_ok;
     int num_results = 0;
 
     *error = NULL;
 
     /* Rules are under the constraints node in the XML, so first find that. */
     cib_constraints = pcmk_find_cib_element(scheduler->input,
                                             PCMK_XE_CONSTRAINTS);
 
     /* Get all rules matching the given ID that are also simple enough for us
      * to check. For the moment, these rules must only have a single
      * date_expression child and:
      * - Do not have a date_spec operation, or
      * - Have a date_spec operation that contains years= but does not contain
      *   moon=.
      *
      * We do this in steps to provide better error messages. First, check that
      * there's any rule with the given ID.
      */
     xpath = crm_strdup_printf(XPATH_NODE_RULE, rule_id);
     xpath_obj = xpath_search(cib_constraints, xpath);
     num_results = numXpathResults(xpath_obj);
 
     free(xpath);
     freeXpathObject(xpath_obj);
 
     if (num_results == 0) {
         *error = "Rule not found";
         return ENXIO;
     }
 
     if (num_results > 1) {
         // Should not be possible; schema prevents this
         *error = "Found more than one rule with matching ID";
         return pcmk_rc_duplicate_id;
     }
 
     /* Next, make sure it has exactly one date_expression. */
     xpath = crm_strdup_printf(XPATH_NODE_RULE "//date_expression", rule_id);
     xpath_obj = xpath_search(cib_constraints, xpath);
     num_results = numXpathResults(xpath_obj);
 
     free(xpath);
     freeXpathObject(xpath_obj);
 
     if (num_results != 1) {
         if (num_results == 0) {
             *error = "Rule does not have a date expression";
         } else {
             *error = "Rule has more than one date expression";
         }
         return EOPNOTSUPP;
     }
 
     /* Then, check that it's something we actually support. */
     xpath = crm_strdup_printf(XPATH_NODE_RULE
                               "//" PCMK_XE_DATE_EXPRESSION
                               "[@" PCMK_XA_OPERATION
                                   "!='" PCMK_VALUE_DATE_SPEC "']",
                               rule_id);
     xpath_obj = xpath_search(cib_constraints, xpath);
     num_results = numXpathResults(xpath_obj);
 
     free(xpath);
 
     if (num_results == 0) {
         freeXpathObject(xpath_obj);
 
         xpath = crm_strdup_printf(XPATH_NODE_RULE
                                   "//" PCMK_XE_DATE_EXPRESSION
                                   "[@" PCMK_XA_OPERATION
                                       "='" PCMK_VALUE_DATE_SPEC "' "
                                   "and " PCMK_XE_DATE_SPEC
                                       "/@" PCMK_XA_YEARS " "
                                   "and not(" PCMK_XE_DATE_SPEC
                                       "/@" PCMK__XA_MOON ")]",
                                   rule_id);
         xpath_obj = xpath_search(cib_constraints, xpath);
         num_results = numXpathResults(xpath_obj);
 
         free(xpath);
 
         if (num_results == 0) {
             freeXpathObject(xpath_obj);
             *error = "Rule must either not use " PCMK_XE_DATE_SPEC ", or use "
                      PCMK_XE_DATE_SPEC " with " PCMK_XA_YEARS "= but not "
                      PCMK__XA_MOON "=";
             return EOPNOTSUPP;
         }
     }
 
     match = getXpathResult(xpath_obj, 0);
 
     /* We should have ensured this with the xpath query above, but double-
      * checking can't hurt.
      */
     CRM_ASSERT(match != NULL);
     CRM_ASSERT(pcmk__condition_type(match) == pcmk__condition_datetime);
 
     rc = pcmk__evaluate_date_expression(match, scheduler->priv->now, NULL);
-    if (rc == pcmk_rc_undetermined) { // Malformed or missing
+    if ((rc != pcmk_rc_ok) && (rc != pcmk_rc_within_range)) {
+        // Malformed or missing
         *error = "Error parsing rule";
     }
 
     freeXpathObject(xpath_obj);
     return rc;
 }
 
 /*!
  * \internal
  * \brief Check whether each rule in a list is in effect
  *
  * \param[in,out] out       Output object
  * \param[in]     input     The CIB XML to check (if \c NULL, use current CIB)
  * \param[in]     date      Check whether the rule is in effect at this date and
  *                          time (if \c NULL, use current date and time)
  * \param[in]     rule_ids  The IDs of the rules to check, as a <tt>NULL</tt>-
  *                          terminated list.
  *
  * \return Standard Pacemaker return code
  */
 int
 pcmk__check_rules(pcmk__output_t *out, xmlNodePtr input, const crm_time_t *date,
                   const char **rule_ids)
 {
     pcmk_scheduler_t *scheduler = NULL;
     int rc = pcmk_rc_ok;
 
     CRM_ASSERT(out != NULL);
 
     if (rule_ids == NULL) {
         // Trivial case; every rule specified is in effect
         return pcmk_rc_ok;
     }
 
     rc = pcmk__init_scheduler(out, input, date, &scheduler);
     if (rc != pcmk_rc_ok) {
         return rc;
     }
 
     for (const char **rule_id = rule_ids; *rule_id != NULL; rule_id++) {
         const char *error = NULL;
         int last_rc = eval_rule(scheduler, *rule_id, &error);
 
         out->message(out, "rule-check", *rule_id, last_rc, error);
 
         if (last_rc != pcmk_rc_ok) {
             rc = last_rc;
         }
     }
 
     pe_free_working_set(scheduler);
     return rc;
 }
 
 // Documented in pacemaker.h
 int
 pcmk_check_rules(xmlNodePtr *xml, xmlNodePtr input, const crm_time_t *date,
                  const char **rule_ids)
 {
     pcmk__output_t *out = NULL;
     int rc = pcmk_rc_ok;
 
     rc = pcmk__xml_output_new(&out, xml);
     if (rc != pcmk_rc_ok) {
         return rc;
     }
 
     pcmk__register_lib_messages(out);
 
     rc = pcmk__check_rules(out, input, date, rule_ids);
     pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
     return rc;
 }