diff --git a/include/pacemaker.h b/include/pacemaker.h
index 6881a81af6..c3e5f4a704 100644
--- a/include/pacemaker.h
+++ b/include/pacemaker.h
@@ -1,668 +1,681 @@
 /*
  * Copyright 2019-2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__PACEMAKER__H
 #  define PCMK__PACEMAKER__H
 
 #  include <glib.h>
 #  include <libxml/tree.h>
 #  include <crm/common/scheduler.h>
 #  include <crm/cib/cib_types.h>
 
 #  include <crm/stonith-ng.h>
 
 #ifdef __cplusplus
 extern "C" {
 #endif
 
 /**
  * \file
  * \brief High Level API
  * \ingroup pacemaker
  */
 
 
 /*!
  * \brief Modify operation of running a cluster simulation.
  */
 enum pcmk_sim_flags {
     pcmk_sim_none             = 0,
     pcmk_sim_all_actions      = 1 << 0,
     pcmk_sim_show_pending     = 1 << 1,
     pcmk_sim_process          = 1 << 2,
     pcmk_sim_show_scores      = 1 << 3,
     pcmk_sim_show_utilization = 1 << 4,
     pcmk_sim_simulate         = 1 << 5,
     pcmk_sim_sanitized        = 1 << 6,
     pcmk_sim_verbose          = 1 << 7,
 };
 
 /*!
  * \brief Synthetic cluster events that can be injected into the cluster
  *        for running simulations.
  */
 typedef struct {
     /*! A list of node names (gchar *) to simulate bringing online */
     GList *node_up;
     /*! A list of node names (gchar *) to simulate bringing offline */
     GList *node_down;
     /*! A list of node names (gchar *) to simulate failing */
     GList *node_fail;
     /*! A list of operations (gchar *) to inject.  The format of these strings
      * is described in the "Operation Specification" section of crm_simulate
      * help output.
      */
     GList *op_inject;
     /*! A list of operations (gchar *) that should return a given error code
      * if they fail.  The format of these strings is described in the
      * "Operation Specification" section of crm_simulate help output.
      */
     GList *op_fail;
     /*! A list of tickets (gchar *) to simulate granting */
     GList *ticket_grant;
     /*! A list of tickets (gchar *) to simulate revoking */
     GList *ticket_revoke;
     /*! A list of tickets (gchar *) to simulate putting on standby */
     GList *ticket_standby;
     /*! A list of tickets (gchar *) to simulate activating */
     GList *ticket_activate;
     /*! Does the cluster have an active watchdog device? */
     char *watchdog;
     /*! Does the cluster have quorum? */
     char *quorum;
 } pcmk_injections_t;
 
 /*!
  * \brief Get and output controller status
  *
  * \param[in,out] xml                 Destination for the result, as an XML tree
  * \param[in]     node_name           Name of node whose status is desired
  *                                    (\p NULL for DC)
  * \param[in]     message_timeout_ms  How long to wait for a reply from the
  *                                    \p pacemaker-controld API. If 0,
  *                                    \p pcmk_ipc_dispatch_sync will be used.
  *                                    Otherwise, \p pcmk_ipc_dispatch_poll will
  *                                    be used.
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_controller_status(xmlNodePtr *xml, const char *node_name,
                            unsigned int message_timeout_ms);
 
 /*!
  * \brief Get and output designated controller node name
  *
  * \param[in,out] xml                 Destination for the result, as an XML tree
  * \param[in]     message_timeout_ms  How long to wait for a reply from the
  *                                    \p pacemaker-controld API. If 0,
  *                                    \p pcmk_ipc_dispatch_sync will be used.
  *                                    Otherwise, \p pcmk_ipc_dispatch_poll will
  *                                    be used.
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_designated_controller(xmlNodePtr *xml,
                                unsigned int message_timeout_ms);
 
 /*!
  * \brief Free a :pcmk_injections_t structure
  *
  * \param[in,out] injections The structure to be freed
  */
 void pcmk_free_injections(pcmk_injections_t *injections);
 
 /*!
  * \brief Get and optionally output node info corresponding to a node ID from
  *        the controller
  *
  * \param[in,out] xml                 Destination for the result, as an XML tree
  * \param[in,out] node_id             ID of node whose name to get. If \p NULL
  *                                    or 0, get the local node name. If not
  *                                    \p NULL, store the true node ID here on
  *                                    success.
  * \param[out]    node_name           If not \p NULL, where to store the node
  *                                    name
  * \param[out]    uuid                If not \p NULL, where to store the node
  *                                    UUID
  * \param[out]    state               If not \p NULL, where to store the
  *                                    membership state
  * \param[out]    is_remote           If not \p NULL, where to store whether the
  *                                    node is a Pacemaker Remote node
  * \param[out]    have_quorum         If not \p NULL, where to store whether the
  *                                    node has quorum
  * \param[in]     show_output         Whether to output the node info
  * \param[in]     message_timeout_ms  How long to wait for a reply from the
  *                                    \p pacemaker-controld API. If 0,
  *                                    \p pcmk_ipc_dispatch_sync will be used.
  *                                    Otherwise, \p pcmk_ipc_dispatch_poll will
  *                                    be used.
  *
  * \return Standard Pacemaker return code
  *
  * \note The caller is responsible for freeing \p *node_name, \p *uuid, and
  *       \p *state using \p free().
  */
 int pcmk_query_node_info(xmlNodePtr *xml, uint32_t *node_id, char **node_name,
                          char **uuid, char **state, bool *have_quorum,
                          bool *is_remote, bool show_output,
                          unsigned int message_timeout_ms);
 
 /*!
  * \brief Get the node name corresponding to a node ID from the controller
  *
  * \param[in,out] xml                 Destination for the result, as an XML tree
  * \param[in,out] node_id             ID of node whose name to get (or 0 for the
  *                                    local node)
  * \param[out]    node_name           If not \p NULL, where to store the node
  *                                    name
  * \param[in]     message_timeout_ms  How long to wait for a reply from the
  *                                    \p pacemaker-controld API. If 0,
  *                                    \p pcmk_ipc_dispatch_sync will be used.
  *                                    Otherwise, \p pcmk_ipc_dispatch_poll will
  *                                    be used.
  *
  * \return Standard Pacemaker return code
  *
  * \note The caller is responsible for freeing \p *node_name using \p free().
  */
 static inline int
 pcmk_query_node_name(xmlNodePtr *xml, uint32_t node_id, char **node_name,
                      unsigned int message_timeout_ms)
 {
     return pcmk_query_node_info(xml, &node_id, node_name, NULL, NULL, NULL,
                                 NULL, false, message_timeout_ms);
 }
 
 /*!
  * \brief Get and output \p pacemakerd status
  *
  * \param[in,out] xml                 Destination for the result, as an XML tree
  * \param[in]     ipc_name            IPC name for request
  * \param[in]     message_timeout_ms  How long to wait for a reply from the
  *                                    \p pacemakerd API. If 0,
  *                                    \p pcmk_ipc_dispatch_sync will be used.
  *                                    Otherwise, \p pcmk_ipc_dispatch_poll will
  *                                    be used.
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_pacemakerd_status(xmlNodePtr *xml, const char *ipc_name,
                            unsigned int message_timeout_ms);
 
 /*!
  * \brief Remove a resource
  *
  * \param[in,out] xml   Destination for the result, as an XML tree
  * \param[in] rsc_id    Resource to remove
  * \param[in] rsc_type  Type of the resource ("primitive", "group", etc.)
  *
  * \return Standard Pacemaker return code
  * \note This function will return \p pcmk_rc_ok if \p rsc_id doesn't exist
  *       or if \p rsc_type is incorrect for \p rsc_id (deleting something
  *       that doesn't exist always succeeds).
  */
 int pcmk_resource_delete(xmlNodePtr *xml, const char *rsc_id, const char *rsc_type);
 
 /*!
  * \brief Calculate and output resource operation digests
  *
  * \param[out]    xml        Where to store XML with result
  * \param[in,out] rsc        Resource to calculate digests for
  * \param[in]     node       Node whose operation history should be used
  * \param[in]     overrides  Hash table of configuration parameters to override
  * \param[in]     scheduler  Scheduler data (with status)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_resource_digests(xmlNodePtr *xml, pcmk_resource_t *rsc,
                           const pcmk_node_t *node, GHashTable *overrides,
                           pcmk_scheduler_t *scheduler);
 
 /*!
  * \brief Simulate a cluster's response to events
  *
  * This high-level function essentially implements crm_simulate(8). It operates
  * on an input CIB file and various lists of events that can be simulated. It
  * optionally writes out a variety of artifacts to show the results of the
  * simulation. Output can be modified with various flags.
  *
  * \param[in,out] xml          The destination for the result, as an XML tree
  * \param[in,out] scheduler    Scheduler data
  * \param[in]     injections   A structure containing cluster events
  *                             (node up/down, tickets, injected operations)
  * \param[in]     flags        A bitfield of :pcmk_sim_flags to modify
  *                             operation of the simulation
  * \param[in]     section_opts Which portions of the cluster status output
  *                             should be displayed?
  * \param[in]     use_date     Date to set the cluster's time to (may be NULL)
  * \param[in]     input_file   The source CIB file, which may be overwritten by
  *                             this function (may be NULL)
  * \param[in]     graph_file   Where to write the XML-formatted transition graph
  *                             (may be NULL, in which case no file will be
  *                             written)
  * \param[in]     dot_file     Where to write the dot(1) formatted transition
  *                             graph (may be NULL, in which case no file will
  *                             be written)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_simulate(xmlNodePtr *xml, pcmk_scheduler_t *scheduler,
                   const pcmk_injections_t *injections, unsigned int flags,
                   unsigned int section_opts, const char *use_date,
                   const char *input_file, const char *graph_file,
                   const char *dot_file);
 
 /*!
  * \brief Verify that a CIB is error-free or output errors and warnings
  *
  * This high-level function essentially implements crm_verify(8). It operates
  * on an input CIB file, which can be inputted through one of several ways. It
  * writes out XML-formatted output.
  *
  * \param[in,out] xml          The destination for the result, as an XML tree
  * \param[in]     cib_source   Source of the CIB: 
  *                             NULL -> use live cib, "-" -> stdin
  *                             "<..." -> xml str, otherwise -> xml file name
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_verify(xmlNodePtr *xml, const char *cib_source);
 
 /*!
  * \brief Get nodes list
  *
  * \param[in,out] xml         The destination for the result, as an XML tree
  * \param[in]     node_types  Node type(s) to return (default: all)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_nodes(xmlNodePtr *xml, const char *node_types);
 
 /*!
  * \brief Output cluster status formatted like `crm_mon --output-as=xml`
  *
  * \param[in,out] xml  The destination for the result, as an XML tree
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_status(xmlNodePtr *xml);
 
 /*!
  * \brief Check whether each rule in a list is in effect
  *
  * \param[in,out] xml       The destination for the result, as an XML tree
  * \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(xmlNodePtr *xml, xmlNodePtr input, const crm_time_t *date,
                      const char **rule_ids);
 
 /*!
  * \brief Check whether a given rule is in effect
  *
  * \param[in,out] xml       The destination for the result, as an XML tree
  * \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 ID of the rule to check
  *
  * \return Standard Pacemaker return code
  */
 static inline int
 pcmk_check_rule(xmlNodePtr *xml, xmlNodePtr input, const crm_time_t *date,
                 const char *rule_id)
 {
     const char *rule_ids[] = {rule_id, NULL};
     return pcmk_check_rules(xml, input, date, rule_ids);
 }
 
 /*!
  * \enum pcmk_rc_disp_flags
  * \brief Bit flags to control which fields of result code info are displayed
  */
 enum pcmk_rc_disp_flags {
     pcmk_rc_disp_none = 0,          //!< (Does nothing)
     pcmk_rc_disp_code = (1 << 0),   //!< Display result code number
     pcmk_rc_disp_name = (1 << 1),   //!< Display result code name
     pcmk_rc_disp_desc = (1 << 2),   //!< Display result code description
 };
 
 /*!
  * \brief Display the name and/or description of a result code
  *
  * \param[in,out] xml    The destination for the result, as an XML tree
  * \param[in]     code   The result code
  * \param[in]     type   Interpret \c code as this type of result code.
  *                       Supported values: \c pcmk_result_legacy,
  *                       \c pcmk_result_rc, \c pcmk_result_exitcode.
  * \param[in]     flags  Group of \c pcmk_rc_disp_flags
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_show_result_code(xmlNodePtr *xml, int code, enum pcmk_result_type type,
                           uint32_t flags);
 
 /*!
  * \brief List all valid result codes in a particular family
  *
  * \param[in,out] xml    The destination for the result, as an XML tree
  * \param[in]     type   The family of result codes to list. Supported
  *                       values: \c pcmk_result_legacy, \c pcmk_result_rc,
  *                       \c pcmk_result_exitcode.
  * \param[in]     flags  Group of \c pcmk_rc_disp_flags
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_result_codes(xmlNodePtr *xml, enum pcmk_result_type type,
                            uint32_t flags);
 
 /*!
  * \brief List available providers for the given OCF agent
  *
  * \param[in,out] xml        The destination for the result, as an XML tree
  * \param[in]     agent_spec Resource agent name
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_alternatives(xmlNodePtr *xml, const char *agent_spec);
 
 /*!
  * \brief List all agents available for the named standard and/or provider
  *
  * \param[in,out] xml        The destination for the result, as an XML tree
  * \param[in]     agent_spec STD[:PROV]
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_agents(xmlNodePtr *xml, char *agent_spec);
 
 /*!
  * \brief List all available OCF providers for the given agent
  *
  * \param[in,out] xml        The destination for the result, as an XML tree
  * \param[in]     agent_spec Resource agent name
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_providers(xmlNodePtr *xml, const char *agent_spec);
 
 /*!
  * \brief List all available resource agent standards
  *
  * \param[in,out] xml        The destination for the result, as an XML tree
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_standards(xmlNodePtr *xml);
 
 /*!
  * \brief List all available cluster options
  *
  * These are options that affect the entire cluster.
  *
  * \param[in,out] xml  The destination for the result, as an XML tree
  * \param[in]     all  If \c true, include advanced and deprecated options
  *                     (currently always treated as true)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_cluster_options(xmlNode **xml, bool all);
 
 /*!
  * \brief List common fencing resource parameters
  *
  * These are parameters that are available for all fencing resources, regardless
  * of type. They are processed by Pacemaker, rather than by the fence agent or
  * the fencing library.
  *
  * \param[in,out] xml  The destination for the result, as an XML tree
  * \param[in]     all  If \c true, include advanced and deprecated options
  *                     (currently always treated as true)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_fencing_params(xmlNode **xml, bool all);
 
 /*!
  * \internal
  * \brief List meta-attributes applicable to primitive resources as OCF-like XML
  *
  * \param[in,out] out  Output object
  * \param[in]     all  If \c true, include advanced and deprecated options (this
  *                     is always treated as true for XML output objects)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_list_primitive_meta(xmlNode **xml, bool all);
 
 /*!
  * \brief Return constraints that apply to the given ticket
  *
  * \param[in,out] xml           The destination for the result, as an XML tree
  * \param[in]     ticket_id     Ticket to find constraint for, or \c NULL for
  *                              all ticket constraints
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_ticket_constraints(xmlNodePtr *xml, const char *ticket_id);
 
+
+/*!
+ * \brief Delete a ticket's state from the local cluster site
+ *
+ * \param[in,out] xml       The destination for the result, as an XML tree
+ * \param[in]     ticket_id Ticket to delete
+ * \param[in]     force     If \c true, delete the ticket even if it has
+ *                          been granted
+ *
+ * \return Standard Pacemaker return code
+ */
+int pcmk_ticket_delete(xmlNodePtr *xml, const char *ticket_id, bool force);
+
 /*!
  * \brief Return the value of a ticket's attribute
  *
  * \param[in,out] xml           The destination for the result, as an XML tree
  * \param[in]     ticket_id     Ticket to find attribute value for
  * \param[in]     attr_name     Attribute's name to find value for
  * \param[in]     attr_default  If either the ticket or the attribute do not
  *                              exist, use this as the value in \p xml
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_ticket_get_attr(xmlNodePtr *xml, const char *ticket_id,
                          const char *attr_name, const char *attr_default);
 
 /*!
  * \brief Return information about the given ticket
  *
  * \param[in,out] xml           The destination for the result, as an XML tree
  * \param[in]     ticket_id     Ticket to find info value for, or \c NULL for
  *                              all tickets
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_ticket_info(xmlNodePtr *xml, const char *ticket_id);
 
 /*!
  * \brief Return a ticket's state XML
  *
  * \param[in,out] xml           The destination for the result, as an XML tree
  * \param[in]     ticket_id     Ticket to find state for, or \c NULL for all
  *                              tickets
  *
  * \return Standard Pacemaker return code
  *
  * \note If \p ticket_id is not \c NULL and more than one ticket exists with
  *       that ID, this function returns \c pcmk_rc_duplicate_id.
  */
 int pcmk_ticket_state(xmlNodePtr *xml, const char *ticket_id);
 
 #ifdef BUILD_PUBLIC_LIBPACEMAKER
 
 /*!
  * \brief Ask the cluster to perform fencing
  *
  * \param[in,out] st        A connection to the fencer API
  * \param[in]     target    The node that should be fenced
  * \param[in]     action    The fencing action (on, off, reboot) to perform
  * \param[in]     name      Who requested the fence action?
  * \param[in]     timeout   How long to wait for operation to complete (in ms)
  * \param[in]     tolerance If a successful action for \p target happened within
  *                          this many ms, return 0 without performing the action
  *                          again
  * \param[in]     delay     Apply this delay (in milliseconds) before initiating
  *                          fencing action (-1 applies no delay and also
  *                          disables any fencing delay from pcmk_delay_base and
  *                          pcmk_delay_max)
  * \param[out]     reason   If not NULL, where to put descriptive failure reason
  *
  * \return Standard Pacemaker return code
  * \note If \p reason is not NULL, the caller is responsible for freeing its
  *       returned value.
  */
 int pcmk_request_fencing(stonith_t *st, const char *target, const char *action,
                          const char *name, unsigned int timeout,
                          unsigned int tolerance, int delay, char **reason);
 
 /*!
  * \brief List the fencing operations that have occurred for a specific node
  *
  * \note If \p xml is not NULL, it will be freed first and the previous
  *       contents lost.
  *
  * \param[in,out] xml       The destination for the result, as an XML tree
  * \param[in,out] st        A connection to the fencer API
  * \param[in]     target    The node to get history for
  * \param[in]     timeout   How long to wait for operation to complete (in ms)
  * \param[in]     quiet     Suppress most output
  * \param[in]     verbose   Include additional output
  * \param[in]     broadcast Gather fencing history from all nodes
  * \param[in]     cleanup   Clean up fencing history after listing
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_history(xmlNodePtr *xml, stonith_t *st, const char *target,
                        unsigned int timeout, bool quiet, int verbose,
                        bool broadcast, bool cleanup);
 
 /*!
  * \brief List all installed fence agents
  *
  * \param[in,out] xml      The destination for the result, as an XML tree (if
  *                         not NULL, previous contents will be freed and lost)
  * \param[in,out] st       A connection to the fencer API
  * \param[in]     timeout  How long to wait for operation to complete (in ms)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_installed(xmlNodePtr *xml, stonith_t *st, unsigned int timeout);
 
 /*!
  * \brief When was a device last fenced?
  *
  * \param[in,out] xml        The destination for the result, as an XML tree (if
  *                           not NULL, previous contents will be freed and lost)
  * \param[in]     target     The node that was fenced
  * \param[in]     as_nodeid  If true, \p target has node ID rather than name
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_last(xmlNodePtr *xml, const char *target, bool as_nodeid);
 
 /*!
  * \brief List nodes that can be fenced
  *
  * \param[in,out] xml        The destination for the result, as an XML tree (if
  *                           not NULL, previous contents will be freed and lost)
  * \param[in,out] st         A connection to the fencer API
  * \param[in]     device_id  Resource ID of fence device to check
  * \param[in]     timeout    How long to wait for operation to complete (in ms)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_list_targets(xmlNodePtr *xml, stonith_t *st,
                             const char *device_id, unsigned int timeout);
 
 /*!
  * \brief Get metadata for a fence agent
  *
  * \note If \p xml is not NULL, it will be freed first and the previous
  *       contents lost.
  *
  * \param[in,out] xml      The destination for the result, as an XML tree (if
  *                         not NULL, previous contents will be freed and lost)
  * \param[in,out] st       A connection to the fencer API
  * \param[in]     agent    The fence agent to get metadata for
  * \param[in]     timeout  How long to wait for operation to complete (in ms)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_metadata(xmlNodePtr *xml, stonith_t *st, const char *agent,
                         unsigned int timeout);
 
 /*!
  * \brief List registered fence devices
  *
  * \param[in,out] xml      The destination for the result, as an XML tree (if
  *                         not NULL, previous contents will be freed and lost)
  * \param[in,out] st       A connection to the fencer API
  * \param[in]     target   If not NULL, return only devices that can fence this
  * \param[in]     timeout  How long to wait for operation to complete (in ms)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_registered(xmlNodePtr *xml, stonith_t *st, const char *target,
                           unsigned int timeout);
 
 /*!
  * \brief Register a fencing topology level
  *
  * \param[in,out] st           A connection to the fencer API
  * \param[in]     target       What fencing level targets (as "name=value" to
  *                             target by given node attribute, or "@pattern" to
  *                             target by node name pattern, or a node name)
  * \param[in]     fence_level  Index number of level to add
  * \param[in]     devices      Devices to use in level
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_register_level(stonith_t *st, const char *target,
                               int fence_level,
                               const stonith_key_value_t *devices);
 
 /*!
  * \brief Unregister a fencing topology level
  *
  * \param[in,out] st           A connection to the fencer API
  * \param[in]     target       What fencing level targets (as "name=value" to
  *                             target by given node attribute, or "@pattern" to
  *                             target by node name pattern, or a node name)
  * \param[in]     fence_level  Index number of level to remove
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_unregister_level(stonith_t *st, const char *target,
                                 int fence_level);
 
 /*!
  * \brief Validate a fence device configuration
  *
  * \param[in,out] xml      The destination for the result, as an XML tree (if
  *                         not NULL, previous contents will be freed and lost)
  * \param[in,out] st       A connection to the fencer API
  * \param[in]     agent    The agent to validate (for example, "fence_xvm")
  * \param[in]     id       Fence device ID (may be NULL)
  * \param[in]     params   Fence device configuration parameters
  * \param[in]     timeout  How long to wait for operation to complete (in ms)
  *
  * \return Standard Pacemaker return code
  */
 int pcmk_fence_validate(xmlNodePtr *xml, stonith_t *st, const char *agent,
                         const char *id, const stonith_key_value_t *params,
                         unsigned int timeout);
 #endif
 
 #ifdef __cplusplus
 }
 #endif
 
 #endif
diff --git a/include/pcmki/pcmki_ticket.h b/include/pcmki/pcmki_ticket.h
index fed2856900..b306cedc96 100644
--- a/include/pcmki/pcmki_ticket.h
+++ b/include/pcmki/pcmki_ticket.h
@@ -1,99 +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 Lesser General Public License
  * version 2.1 or later (LGPLv2.1+) WITHOUT ANY WARRANTY.
  */
 
 #ifndef PCMK__PCMKI_PCMKI_TICKET__H
 #  define PCMK__PCMKI_PCMKI_TICKET__H
 
 #include <crm/common/output_internal.h>
 
 #include <crm/cib/cib_types.h>
 
 /*!
  * \internal
  * \brief Return the state XML for a given ticket
  *
  * \param[in]  cib          Open CIB connection
  * \param[in]  ticket_id    Ticket to get state for, or \c NULL for all tickets
  * \param[out] state        Where to store the result XML
  *
  * \return Standard Pacemaker return code
  *
  * \note If \p ticket_id is not \c NULL and more than one ticket exists with
  *       that ID, this function returns \c pcmk_rc_duplicate_id.
  */
 int pcmk__get_ticket_state(cib_t *cib, const char *ticket_id, xmlNode **state);
 
 /*!
  * \internal
  * \brief Display the constraints that apply to a given ticket
  *
  * \param[in,out]   out         Output object
  * \param[in]       cib         Open CIB connection
  * \param[in]       ticket_id   Ticket to find constraints for,
  *                              or \c NULL for all ticket constraints
  *
  * \return Standard Pacemaker return code
  */
 int pcmk__ticket_constraints(pcmk__output_t *out, cib_t *cib, const char *ticket_id);
 
+/*!
+ * \internal
+ * \brief Delete a ticket's state from the local cluster site
+ *
+ * \param[in,out]   out         Output object
+ * \param[in]       cib         Open CIB connection
+ * \param[in]       scheduler   Scheduler data
+ * \param[in]       ticket_id   Ticket to delete
+ * \param[in]       force       If \c true, delete the ticket even if it has
+ *                              been granted
+ *
+ * \return Standard Pacemaker return code
+ */
+int pcmk__ticket_delete(pcmk__output_t *out, cib_t *cib, pcmk_scheduler_t *scheduler,
+                        const char *ticket_id, bool force);
+
 /*!
  * \internal
  * \brief Return the value of a ticket's attribute
  *
  * \param[in,out]   out             Output object
  * \param[in,out]   scheduler       Scheduler data
  * \param[in]       ticket_id       Ticket to find attribute value for
  * \param[in]       attr_name       Attribute's name to find value for
  * \param[in]       attr_default    If either the ticket or the attribute do not
  *                                  exist, use this as the value in the output
  *
  * \return Standard Pacemaker return code
  */
 int pcmk__ticket_get_attr(pcmk__output_t *out, pcmk_scheduler_t *scheduler,
                           const char *ticket_id, const char *attr_name,
                           const char *attr_default);
 
 /*!
  * \brief Return information about the given ticket
  *
  * \param[in,out] out           Output object
  * \param[in,out] scheduler     Scheduler data
  * \param[in]     ticket_id     Ticket to display info for, or \c NULL for
  *                              all tickets
  * \param[in]     details       If true (and \p out is not an XML format
  *                              object), output any additional attributes
  *                              set on a ticket beyond the basics
  * \param[in]     raw           If true (and \p out is not an XML format
  *                              object), simply list the IDs of all tickets.
  *                              This does not make a lot of sense if
  *                              \p ticket_id is not NULL, but that will not
  *                              raise an error.
  *
  * \return Standard Pacemaker return code
  */
 int pcmk__ticket_info(pcmk__output_t *out, pcmk_scheduler_t *scheduler,
                       const char *ticket_id, bool details, bool raw);
 
 /*!
  * \internal
  * \brief Return a ticket's state XML
  *
  * \param[in,out]   out         Output object
  * \param[in]       cib         Open CIB connection
  * \param[in]       ticket_id   Ticket to find constraints for,
  *                              or \c NULL for all ticket constraints
  *
  * \return Standard Pacemaker return code
  *
  * \note If \p ticket_id is not \c NULL and more than one ticket exists with
  *       that ID, this function returns \c pcmk_rc_duplicate_id.
  */
 int pcmk__ticket_state(pcmk__output_t *out, cib_t *cib, const char *ticket_id);
 
 #endif
diff --git a/lib/pacemaker/pcmk_ticket.c b/lib/pacemaker/pcmk_ticket.c
index 274a7d757e..912c4b6d9b 100644
--- a/lib/pacemaker/pcmk_ticket.c
+++ b/lib/pacemaker/pcmk_ticket.c
@@ -1,266 +1,357 @@
 /*
  * Copyright 2024 the Pacemaker project contributors
  *
  * The version control history for this file may have further details.
  *
  * This source code is licensed under the GNU General Public License version 2
  * or later (GPLv2+) WITHOUT ANY WARRANTY.
  */
 
 #include <crm_internal.h>
 
 #include <crm/cib/internal.h>
 #include <crm/pengine/internal.h>
 
 #include <pacemaker.h>
 #include <pacemaker-internal.h>
 
 #include "libpacemaker_private.h"
 
 int
 pcmk__get_ticket_state(cib_t *cib, const char *ticket_id, xmlNode **state)
 {
     int rc = pcmk_rc_ok;
     xmlNode *xml_search = NULL;
     char *xpath = NULL;
 
     CRM_ASSERT(cib!= NULL && state != NULL);
     *state = NULL;
 
     if (ticket_id != NULL) {
         xpath = crm_strdup_printf("/" PCMK_XE_CIB "/" PCMK_XE_STATUS "/" PCMK_XE_TICKETS
                                   "/" PCMK__XE_TICKET_STATE "[@" PCMK_XA_ID "=\"%s\"]",
                                   ticket_id);
     } else {
         xpath = crm_strdup_printf("/" PCMK_XE_CIB "/" PCMK_XE_STATUS "/" PCMK_XE_TICKETS);
     }
 
     rc = cib->cmds->query(cib, xpath, &xml_search,
                           cib_sync_call | cib_scope_local | cib_xpath);
     rc = pcmk_legacy2rc(rc);
 
     if (rc == pcmk_rc_ok) {
         crm_log_xml_debug(xml_search, "Match");
 
         if (xml_search->children != NULL && ticket_id != NULL) {
             rc = pcmk_rc_duplicate_id;
         }
     }
 
     free(xpath);
 
     *state = xml_search;
     return rc;
 }
 
 int
 pcmk__ticket_constraints(pcmk__output_t *out, cib_t *cib, const char *ticket_id)
 {
     int rc = pcmk_rc_ok;
     xmlNode *result = NULL;
     const char *xpath_base = NULL;
     char *xpath = NULL;
 
     CRM_ASSERT(out != NULL && cib != NULL);
 
     xpath_base = pcmk_cib_xpath_for(PCMK_XE_CONSTRAINTS);
     CRM_ASSERT(xpath_base != NULL);
 
     if (ticket_id != NULL) {
         xpath = crm_strdup_printf("%s/" PCMK_XE_RSC_TICKET "[@" PCMK_XA_TICKET "=\"%s\"]",
                                   xpath_base, ticket_id);
     } else {
         xpath = crm_strdup_printf("%s/" PCMK_XE_RSC_TICKET, xpath_base);
     }
 
     rc = cib->cmds->query(cib, (const char *) xpath, &result,
                           cib_sync_call | cib_scope_local | cib_xpath);
     rc = pcmk_legacy2rc(rc);
 
     if (result != NULL) {
         out->message(out, "ticket-constraints", result);
         free_xml(result);
     }
 
     free(xpath);
     return rc;
 }
 
 int
 pcmk_ticket_constraints(xmlNodePtr *xml, const char *ticket_id)
 {
     pcmk__output_t *out = NULL;
     int rc = pcmk_rc_ok;
     cib_t *cib = NULL;
 
     rc = pcmk__setup_output_cib_sched(&out, &cib, NULL, xml);
     if (rc != pcmk_rc_ok) {
         goto done;
     }
 
     rc = pcmk__ticket_constraints(out, cib, ticket_id);
 
 done:
     if (cib != NULL) {
         cib__clean_up_connection(&cib);
     }
 
     pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
     return rc;
 }
 
+static int
+delete_single_ticket(xmlNode *child, void *userdata)
+{
+    int rc = pcmk_rc_ok;
+    cib_t *cib = (cib_t *) userdata;
+
+    rc = cib->cmds->remove(cib, PCMK_XE_STATUS, child, cib_sync_call);
+    rc = pcmk_legacy2rc(rc);
+
+    return rc;
+}
+
+int
+pcmk__ticket_delete(pcmk__output_t *out, cib_t *cib, pcmk_scheduler_t *scheduler,
+                    const char *ticket_id, bool force)
+{
+    int rc = pcmk_rc_ok;
+    xmlNode *state = NULL;
+
+    CRM_ASSERT(cib != NULL && scheduler != NULL);
+
+    if (ticket_id == NULL) {
+        return EINVAL;
+    }
+
+    if (!force) {
+        pcmk_ticket_t *ticket = g_hash_table_lookup(scheduler->tickets, ticket_id);
+
+        if (ticket == NULL) {
+            return ENXIO;
+        }
+
+        if (ticket->granted) {
+            return EACCES;
+        }
+    }
+
+    rc = pcmk__get_ticket_state(cib, ticket_id, &state);
+
+    if (rc == pcmk_rc_duplicate_id) {
+        out->info(out, "Multiple " PCMK__XE_TICKET_STATE "s match ticket=%s",
+                  ticket_id);
+
+    } else if (rc == ENXIO) {
+        return pcmk_rc_ok;
+
+    } else if (rc != pcmk_rc_ok) {
+        return rc;
+    }
+
+    crm_log_xml_debug(state, "Delete");
+
+    if (rc == pcmk_rc_duplicate_id) {
+        rc = pcmk__xe_foreach_child(state, NULL, delete_single_ticket, cib);
+    } else {
+        rc = delete_single_ticket(state, cib);
+    }
+
+    if (rc == pcmk_rc_ok) {
+        out->info(out, "Cleaned up %s", ticket_id);
+    }
+
+    free_xml(state);
+    return rc;
+}
+
+int
+pcmk_ticket_delete(xmlNodePtr *xml, const char *ticket_id, bool force)
+{
+    pcmk_scheduler_t *scheduler = NULL;
+    pcmk__output_t *out = NULL;
+    cib_t *cib = NULL;
+    int rc = pcmk_rc_ok;
+
+    rc = pcmk__setup_output_cib_sched(&out, &cib, &scheduler, xml);
+    if (rc != pcmk_rc_ok) {
+        goto done;
+    }
+
+    rc = pcmk__ticket_delete(out, cib, scheduler, ticket_id, force);
+
+done:
+    if (cib != NULL) {
+        cib__clean_up_connection(&cib);
+    }
+
+    pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
+    pe_free_working_set(scheduler);
+    return rc;
+}
+
 int
 pcmk__ticket_get_attr(pcmk__output_t *out, pcmk_scheduler_t *scheduler,
                       const char *ticket_id, const char *attr_name,
                       const char *attr_default)
 {
     int rc = pcmk_rc_ok;
     const char *attr_value = NULL;
     pcmk_ticket_t *ticket = NULL;
 
     CRM_ASSERT(out != NULL && scheduler != NULL);
 
     if (ticket_id == NULL || attr_name == NULL) {
         return EINVAL;
     }
 
     ticket = g_hash_table_lookup(scheduler->tickets, ticket_id);
 
     if (ticket != NULL) {
         attr_value = g_hash_table_lookup(ticket->state, attr_name);
     }
 
     if (attr_value != NULL) {
         out->message(out, "ticket-attribute", ticket_id, attr_name, attr_value);
     } else if (attr_default != NULL) {
         out->message(out, "ticket-attribute", ticket_id, attr_name, attr_default);
     } else {
         rc = ENXIO;
     }
 
     return rc;
 }
 
 int
 pcmk_ticket_get_attr(xmlNodePtr *xml, const char *ticket_id,
                      const char *attr_name, const char *attr_default)
 {
     pcmk_scheduler_t *scheduler = NULL;
     pcmk__output_t *out = NULL;
     int rc = pcmk_rc_ok;
 
     rc = pcmk__setup_output_cib_sched(&out, NULL, &scheduler, xml);
     if (rc != pcmk_rc_ok) {
         goto done;
     }
 
     rc = pcmk__ticket_get_attr(out, scheduler, ticket_id, attr_name, attr_default);
 
 done:
     pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
     pe_free_working_set(scheduler);
     return rc;
 }
 
 int
 pcmk__ticket_info(pcmk__output_t *out, pcmk_scheduler_t *scheduler,
                   const char *ticket_id, bool details, bool raw)
 {
     int rc = pcmk_rc_ok;
 
     CRM_ASSERT(out != NULL && scheduler != NULL);
 
     if (ticket_id != NULL) {
         GHashTable *tickets = NULL;
         pcmk_ticket_t *ticket = g_hash_table_lookup(scheduler->tickets, ticket_id);
 
         if (ticket == NULL) {
             return ENXIO;
         }
 
         /* The ticket-list message expects a GHashTable, so we'll construct
          * one with just this single item.
          */
         tickets = pcmk__strkey_table(free, NULL);
         g_hash_table_insert(tickets, strdup(ticket->id), ticket);
         out->message(out, "ticket-list", tickets, false, raw, details);
         g_hash_table_destroy(tickets);
 
     } else {
         out->message(out, "ticket-list", scheduler->tickets, false, raw, details);
     }
 
     return rc;
 }
 
 int
 pcmk_ticket_info(xmlNodePtr *xml, const char *ticket_id)
 {
     pcmk_scheduler_t *scheduler = NULL;
     pcmk__output_t *out = NULL;
     int rc = pcmk_rc_ok;
 
     rc = pcmk__setup_output_cib_sched(&out, NULL, &scheduler, xml);
     if (rc != pcmk_rc_ok) {
         goto done;
     }
 
     pe__register_messages(out);
 
     /* XML output (which is the only format supported by public API functions
      * due to the use of pcmk__xml_output_new above) always prints all details,
      * so just pass false for the last two arguments.
      */
     rc = pcmk__ticket_info(out, scheduler, ticket_id, false, false);
 
 done:
     pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
     pe_free_working_set(scheduler);
     return rc;
 }
 
 int
 pcmk__ticket_state(pcmk__output_t *out, cib_t *cib, const char *ticket_id)
 {
     xmlNode *state_xml = NULL;
     int rc = pcmk_rc_ok;
 
     CRM_ASSERT(out != NULL && cib != NULL);
 
     rc = pcmk__get_ticket_state(cib, ticket_id, &state_xml);
 
     if (rc == pcmk_rc_duplicate_id) {
         out->info(out, "Multiple " PCMK__XE_TICKET_STATE "s match ticket=%s",
                   ticket_id);
     }
 
     if (state_xml != NULL) {
         out->message(out, "ticket-state", state_xml);
         free_xml(state_xml);
     }
 
     return rc;
 }
 
 int
 pcmk_ticket_state(xmlNodePtr *xml, const char *ticket_id)
 {
     pcmk__output_t *out = NULL;
     int rc = pcmk_rc_ok;
     cib_t *cib = NULL;
 
     rc = pcmk__setup_output_cib_sched(&out, &cib, NULL, xml);
     if (rc != pcmk_rc_ok) {
         goto done;
     }
 
     rc = pcmk__ticket_state(out, cib, ticket_id);
 
 done:
     if (cib != NULL) {
         cib__clean_up_connection(&cib);
     }
 
     pcmk__xml_output_finish(out, pcmk_rc2exitc(rc), xml);
     return rc;
 }
diff --git a/tools/crm_ticket.c b/tools/crm_ticket.c
index dae2d656b4..1a13e77cad 100644
--- a/tools/crm_ticket.c
+++ b/tools/crm_ticket.c
@@ -1,798 +1,764 @@
 /*
  * Copyright 2012-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 <sys/param.h>
 
 #include <crm/crm.h>
 
 #include <stdio.h>
 #include <sys/types.h>
 #include <unistd.h>
 
 #include <stdlib.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <libgen.h>
 
 #include <crm/common/xml.h>
 #include <crm/common/ipc.h>
 #include <crm/common/cmdline_internal.h>
 
 #include <crm/cib.h>
 #include <crm/cib/internal.h>
 #include <crm/pengine/rules.h>
 #include <crm/pengine/status.h>
 #include <crm/pengine/internal.h>
 
 #include <pacemaker-internal.h>
 
 GError *error = NULL;
 
 #define SUMMARY "Perform tasks related to cluster tickets\n\n" \
                 "Allows ticket attributes to be queried, modified and deleted."
 
 struct {
     gchar *attr_default;
     gchar *attr_id;
     char *attr_name;
     char *attr_value;
     gboolean force;
     char *get_attr_name;
     gboolean quiet;
     gchar *set_name;
     char ticket_cmd;
     gchar *ticket_id;
     gchar *xml_file;
 } options = {
     .ticket_cmd = 'S'
 };
 
 GList *attr_delete;
 GHashTable *attr_set;
 bool modified = false;
 int cib_options = cib_sync_call;
 static pcmk__output_t *out = NULL;
 
 #define INDENT "                               "
 
 static pcmk__supported_format_t formats[] = {
     PCMK__SUPPORTED_FORMAT_NONE,
     PCMK__SUPPORTED_FORMAT_TEXT,
     PCMK__SUPPORTED_FORMAT_XML,
     { NULL, NULL, NULL }
 };
 
 static gboolean
 attr_value_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     pcmk__str_update(&options.attr_value, optarg);
 
     if (!options.attr_name || !options.attr_value) {
         return TRUE;
     }
 
     pcmk__insert_dup(attr_set, options.attr_name, options.attr_value);
     pcmk__str_update(&options.attr_name, NULL);
     pcmk__str_update(&options.attr_value, NULL);
 
     modified = true;
 
     return TRUE;
 }
 
 static gboolean
 command_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     if (pcmk__str_any_of(option_name, "--info", "-l", NULL)) {
         options.ticket_cmd = 'l';
     } else if (pcmk__str_any_of(option_name, "--details", "-L", NULL)) {
         options.ticket_cmd = 'L';
     } else if (pcmk__str_any_of(option_name, "--raw", "-w", NULL)) {
         options.ticket_cmd = 'w';
     } else if (pcmk__str_any_of(option_name, "--query-xml", "-q", NULL)) {
         options.ticket_cmd = 'q';
     } else if (pcmk__str_any_of(option_name, "--constraints", "-c", NULL)) {
         options.ticket_cmd = 'c';
     } else if (pcmk__str_any_of(option_name, "--cleanup", "-C", NULL)) {
         options.ticket_cmd = 'C';
     }
 
     return TRUE;
 }
 
 static gboolean
 delete_attr_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     attr_delete = g_list_append(attr_delete, strdup(optarg));
     modified = true;
     return TRUE;
 }
 
 static gboolean
 get_attr_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     pcmk__str_update(&options.get_attr_name, optarg);
     options.ticket_cmd = 'G';
     return TRUE;
 }
 
 static gboolean
 grant_standby_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     if (pcmk__str_any_of(option_name, "--grant", "-g", NULL)) {
         pcmk__insert_dup(attr_set, PCMK__XA_GRANTED, PCMK_VALUE_TRUE);
         modified = true;
     } else if (pcmk__str_any_of(option_name, "--revoke", "-r", NULL)) {
         pcmk__insert_dup(attr_set, PCMK__XA_GRANTED, PCMK_VALUE_FALSE);
         modified = true;
     } else if (pcmk__str_any_of(option_name, "--standby", "-s", NULL)) {
         pcmk__insert_dup(attr_set, PCMK_XA_STANDBY, PCMK_VALUE_TRUE);
         modified = true;
     } else if (pcmk__str_any_of(option_name, "--activate", "-a", NULL)) {
         pcmk__insert_dup(attr_set, PCMK_XA_STANDBY, PCMK_VALUE_FALSE);
         modified = true;
     }
 
     return TRUE;
 }
 
 static gboolean
 set_attr_cb(const gchar *option_name, const gchar *optarg, gpointer data, GError **err) {
     pcmk__str_update(&options.attr_name, optarg);
 
     if (!options.attr_name || !options.attr_value) {
         return TRUE;
     }
 
     pcmk__insert_dup(attr_set, options.attr_name, options.attr_value);
     pcmk__str_update(&options.attr_name, NULL);
     pcmk__str_update(&options.attr_value, NULL);
 
     modified = true;
 
     return TRUE;
 }
 
 static GOptionEntry query_entries[] = {
     { "info", 'l', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the information of ticket(s)",
       NULL },
 
     { "details", 'L', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the details of ticket(s)",
       NULL },
 
     { "raw", 'w', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the IDs of ticket(s)",
       NULL },
 
     { "query-xml", 'q', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Query the XML of ticket(s)",
       NULL },
 
     { "constraints", 'c', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Display the " PCMK_XE_RSC_TICKET " constraints that apply to ticket(s)",
       NULL },
 
     { NULL }
 };
 
 static GOptionEntry command_entries[] = {
     { "grant", 'g', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, grant_standby_cb,
       "Grant a ticket to this cluster site",
       NULL },
 
     { "revoke", 'r', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, grant_standby_cb,
       "Revoke a ticket from this cluster site",
       NULL },
 
     { "standby", 's', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, grant_standby_cb,
       "Tell this cluster site this ticket is standby",
       NULL },
 
     { "activate", 'a', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, grant_standby_cb,
       "Tell this cluster site this ticket is active",
       NULL },
 
     { NULL }
 };
 
 static GOptionEntry advanced_entries[] = {
     { "get-attr", 'G', 0, G_OPTION_ARG_CALLBACK, get_attr_cb,
       "Display the named attribute for a ticket",
       "ATTRIBUTE" },
 
     { "set-attr", 'S', 0, G_OPTION_ARG_CALLBACK, set_attr_cb,
       "Set the named attribute for a ticket",
       "ATTRIBUTE" },
 
     { "delete-attr", 'D', 0, G_OPTION_ARG_CALLBACK, delete_attr_cb,
       "Delete the named attribute for a ticket",
       "ATTRIBUTE" },
 
     { "cleanup", 'C', G_OPTION_FLAG_NO_ARG, G_OPTION_ARG_CALLBACK, command_cb,
       "Delete all state of a ticket at this cluster site",
       NULL },
 
     { NULL}
 };
 
 static GOptionEntry addl_entries[] = {
     { "attr-value", 'v', 0, G_OPTION_ARG_CALLBACK, attr_value_cb,
       "Attribute value to use with -S",
       "VALUE" },
 
     { "default", 'd', 0, G_OPTION_ARG_STRING, &options.attr_default,
       "(Advanced) Default attribute value to display if none is found\n"
       INDENT "(for use with -G)",
       "VALUE" },
 
     { "force", 'f', 0, G_OPTION_ARG_NONE, &options.force,
       "(Advanced) Force the action to be performed",
       NULL },
 
     { "ticket", 't', 0, G_OPTION_ARG_STRING, &options.ticket_id,
       "Ticket ID",
       "ID" },
 
     { "xml-file", 'x', G_OPTION_FLAG_HIDDEN, G_OPTION_ARG_STRING, &options.xml_file,
       NULL,
       NULL },
 
     { NULL }
 };
 
 static GOptionEntry deprecated_entries[] = {
     { "set-name", 'n', 0, G_OPTION_ARG_STRING, &options.set_name,
       "(Advanced) ID of the " PCMK_XE_INSTANCE_ATTRIBUTES " object to change",
       "ID" },
 
     { "nvpair", 'i', 0, G_OPTION_ARG_STRING, &options.attr_id,
       "(Advanced) ID of the nvpair object to change/delete",
       "ID" },
 
     { "quiet", 'Q', 0, G_OPTION_ARG_NONE, &options.quiet,
       "Print only the value on stdout",
       NULL },
 
     { NULL }
 };
 
 static pcmk_ticket_t *
 find_ticket(gchar *ticket_id, pcmk_scheduler_t *scheduler)
 {
     return g_hash_table_lookup(scheduler->tickets, ticket_id);
 }
 
 static void
 ticket_grant_warning(gchar *ticket_id)
 {
     out->err(out, "This command cannot help you verify whether '%s' has "
                   "been already granted elsewhere.\n"
                   "If you really want to grant '%s' to this site now, and "
                   "you know what you are doing,\n"
                   "please specify --force.",
                   ticket_id, ticket_id);
 }
 
 static void
 ticket_revoke_warning(gchar *ticket_id)
 {
     out->err(out, "Revoking '%s' can trigger the specified '" PCMK_XA_LOSS_POLICY
               "'(s) relating to '%s'.\n\n"
               "You can check that with:\n"
               "crm_ticket --ticket %s --constraints\n\n"
               "Otherwise before revoking '%s', you may want to make '%s'"
               "standby with:\n"
               "crm_ticket --ticket %s --standby\n\n"
               "If you really want to revoke '%s' from this site now, and "
               "you know what you are doing,\n"
               "please specify --force.",
               ticket_id, ticket_id, ticket_id, ticket_id, ticket_id,
               ticket_id, ticket_id);
 }
 
 static bool
 allow_modification(gchar *ticket_id)
 {
     const char *value = NULL;
     GList *list_iter = NULL;
 
     if (options.force) {
         return true;
     }
 
     if (g_hash_table_lookup_extended(attr_set, PCMK__XA_GRANTED, NULL,
                                      (gpointer *) &value)) {
         if (crm_is_true(value)) {
             ticket_grant_warning(ticket_id);
             return false;
 
         } else {
             ticket_revoke_warning(ticket_id);
             return false;
         }
     }
 
     for(list_iter = attr_delete; list_iter; list_iter = list_iter->next) {
         const char *key = (const char *)list_iter->data;
 
         if (pcmk__str_eq(key, PCMK__XA_GRANTED, pcmk__str_none)) {
             ticket_revoke_warning(ticket_id);
             return false;
         }
     }
 
     return true;
 }
 
 static int
 modify_ticket_state(gchar *ticket_id, cib_t *cib, pcmk_scheduler_t *scheduler)
 {
     int rc = pcmk_rc_ok;
     xmlNode *xml_top = NULL;
     xmlNode *ticket_state_xml = NULL;
     bool found = false;
 
     GList *list_iter = NULL;
     GHashTableIter hash_iter;
 
     char *key = NULL;
     char *value = NULL;
 
     pcmk_ticket_t *ticket = NULL;
 
     rc = pcmk__get_ticket_state(cib, ticket_id, &ticket_state_xml);
 
     if (rc == pcmk_rc_duplicate_id) {
         out->info(out, "Multiple " PCMK__XE_TICKET_STATE "s match ticket=%s",
                   ticket_id);
         rc = pcmk_rc_ok;
     }
 
     if (rc == pcmk_rc_ok) {
         crm_debug("Found a match state for ticket: id=%s", ticket_id);
         xml_top = ticket_state_xml;
         found = true;
 
     } else if (rc != ENXIO) {
         return rc;
 
     } else if (g_hash_table_size(attr_set) == 0){
         return pcmk_rc_ok;
 
     } else {
         xmlNode *xml_obj = NULL;
 
         xml_top = pcmk__xe_create(NULL, PCMK_XE_STATUS);
         xml_obj = pcmk__xe_create(xml_top, PCMK_XE_TICKETS);
         ticket_state_xml = pcmk__xe_create(xml_obj, PCMK__XE_TICKET_STATE);
         crm_xml_add(ticket_state_xml, PCMK_XA_ID, ticket_id);
     }
 
     for(list_iter = attr_delete; list_iter; list_iter = list_iter->next) {
         const char *key = (const char *)list_iter->data;
         pcmk__xe_remove_attr(ticket_state_xml, key);
     }
 
     ticket = find_ticket(ticket_id, scheduler);
 
     g_hash_table_iter_init(&hash_iter, attr_set);
     while (g_hash_table_iter_next(&hash_iter, (gpointer *) & key, (gpointer *) & value)) {
         crm_xml_add(ticket_state_xml, key, value);
 
         if (pcmk__str_eq(key, PCMK__XA_GRANTED, pcmk__str_none)
             && (ticket == NULL || ticket->granted == FALSE)
             && crm_is_true(value)) {
 
             char *now = pcmk__ttoa(time(NULL));
 
             crm_xml_add(ticket_state_xml, PCMK_XA_LAST_GRANTED, now);
             free(now);
         }
     }
 
     if (found && (attr_delete != NULL)) {
         crm_log_xml_debug(xml_top, "Replace");
         rc = cib->cmds->replace(cib, PCMK_XE_STATUS, ticket_state_xml,
                                 cib_options);
         rc = pcmk_legacy2rc(rc);
 
     } else {
         crm_log_xml_debug(xml_top, "Update");
         rc = cib->cmds->modify(cib, PCMK_XE_STATUS, xml_top, cib_options);
         rc = pcmk_legacy2rc(rc);
     }
 
     free_xml(xml_top);
     return rc;
 }
 
-static int
-delete_ticket_state(gchar *ticket_id, cib_t * cib)
-{
-    xmlNode *ticket_state_xml = NULL;
-
-    int rc = pcmk__get_ticket_state(cib, ticket_id, &ticket_state_xml);
-
-    if (rc == pcmk_rc_duplicate_id) {
-        out->info(out, "Multiple " PCMK__XE_TICKET_STATE "s match ticket=%s",
-                  ticket_id);
-        rc = pcmk_rc_ok;
-
-    } else if (rc == ENXIO) {
-        return pcmk_rc_ok;
-
-    } else if (rc != pcmk_rc_ok) {
-        return rc;
-    }
-
-    crm_log_xml_debug(ticket_state_xml, "Delete");
-
-    rc = cib->cmds->remove(cib, PCMK_XE_STATUS, ticket_state_xml, cib_options);
-    rc = pcmk_legacy2rc(rc);
-
-    if (rc == pcmk_rc_ok) {
-        out->info(out, "Cleaned up %s", ticket_id);
-    }
-
-    free_xml(ticket_state_xml);
-    return rc;
-}
-
 static GOptionContext *
 build_arg_context(pcmk__common_args_t *args, GOptionGroup **group)
 {
     GOptionContext *context = NULL;
 
     const char *description = "Examples:\n\n"
                               "Display the info of tickets:\n\n"
                               "\tcrm_ticket --info\n\n"
                               "Display the detailed info of tickets:\n\n"
                               "\tcrm_ticket --details\n\n"
                               "Display the XML of 'ticketA':\n\n"
                               "\tcrm_ticket --ticket ticketA --query-xml\n\n"
                               "Display the " PCMK_XE_RSC_TICKET " constraints that apply to 'ticketA':\n\n"
                               "\tcrm_ticket --ticket ticketA --constraints\n\n"
                               "Grant 'ticketA' to this cluster site:\n\n"
                               "\tcrm_ticket --ticket ticketA --grant\n\n"
                               "Revoke 'ticketA' from this cluster site:\n\n"
                               "\tcrm_ticket --ticket ticketA --revoke\n\n"
                               "Make 'ticketA' standby (the cluster site will treat a granted\n"
                               "'ticketA' as 'standby', and the dependent resources will be\n"
                               "stopped or demoted gracefully without triggering loss-policies):\n\n"
                               "\tcrm_ticket --ticket ticketA --standby\n\n"
                               "Activate 'ticketA' from being standby:\n\n"
                               "\tcrm_ticket --ticket ticketA --activate\n\n"
                               "Get the value of the 'granted' attribute for 'ticketA':\n\n"
                               "\tcrm_ticket --ticket ticketA --get-attr granted\n\n"
                               "Set the value of the 'standby' attribute for 'ticketA':\n\n"
                               "\tcrm_ticket --ticket ticketA --set-attr standby --attr-value true\n\n"
                               "Delete the 'granted' attribute for 'ticketA':\n\n"
                               "\tcrm_ticket --ticket ticketA --delete-attr granted\n\n"
                               "Erase the operation history of 'ticketA' at this cluster site,\n"
                               "causing the cluster site to 'forget' the existing ticket state:\n\n"
                               "\tcrm_ticket --ticket ticketA --cleanup\n\n";
 
     context = pcmk__build_arg_context(args, "text (default), xml", group, NULL);
     g_option_context_set_description(context, description);
 
     pcmk__add_arg_group(context, "queries", "Queries:",
                         "Show queries", query_entries);
     pcmk__add_arg_group(context, "commands", "Commands:",
                         "Show command options", command_entries);
     pcmk__add_arg_group(context, "advanced", "Advanced Options:",
                         "Show advanced options", advanced_entries);
     pcmk__add_arg_group(context, "additional", "Additional Options:",
                         "Show additional options", addl_entries);
     pcmk__add_arg_group(context, "deprecated", "Deprecated Options:",
                         "Show deprecated options", deprecated_entries);
 
     return context;
 }
 
 int
 main(int argc, char **argv)
 {
     pcmk_scheduler_t *scheduler = NULL;
     xmlNode *cib_xml_copy = NULL;
 
     cib_t *cib_conn = NULL;
     crm_exit_t exit_code = CRM_EX_OK;
     int rc = pcmk_rc_ok;
 
     GOptionGroup *output_group = NULL;
     pcmk__common_args_t *args = NULL;
     GOptionContext *context = NULL;
     gchar **processed_args = NULL;
 
     attr_set = pcmk__strkey_table(free, free);
     attr_delete = NULL;
 
     args = pcmk__new_common_args(SUMMARY);
     context = build_arg_context(args, &output_group);
     processed_args = pcmk__cmdline_preproc(argv, "dintvxCDGS");
 
     pcmk__register_formats(output_group, formats);
     if (!g_option_context_parse_strv(context, &processed_args, &error)) {
         exit_code = CRM_EX_USAGE;
         goto done;
     }
 
     pcmk__cli_init_logging("crm_ticket", args->verbosity);
 
     rc = pcmk__output_new(&out, args->output_ty, args->output_dest, argv);
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Error creating output format %s: %s", args->output_ty,
                     pcmk_rc_str(rc));
         goto done;
     }
 
     pe__register_messages(out);
     pcmk__register_lib_messages(out);
 
     if (args->version) {
         out->version(out, false);
         goto done;
     }
 
     scheduler = pe_new_working_set();
     if (scheduler == NULL) {
         rc = errno;
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Could not allocate scheduler data: %s", pcmk_rc_str(rc));
         goto done;
     }
     pcmk__set_scheduler_flags(scheduler,
                               pcmk_sched_no_counts|pcmk_sched_no_compat);
 
     cib_conn = cib_new();
     if (cib_conn == NULL) {
         exit_code = CRM_EX_DISCONNECT;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code, "Could not connect to the CIB manager");
         goto done;
     }
 
     rc = cib_conn->cmds->signon(cib_conn, crm_system_name, cib_command);
     rc = pcmk_legacy2rc(rc);
 
     if (rc != pcmk_rc_ok) {
         exit_code = pcmk_rc2exitc(rc);
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code, "Could not connect to the CIB: %s",
                     pcmk_rc_str(rc));
         goto done;
     }
 
     if (options.xml_file != NULL) {
         cib_xml_copy = pcmk__xml_read(options.xml_file);
 
     } else {
         rc = cib_conn->cmds->query(cib_conn, NULL, &cib_xml_copy, cib_scope_local | cib_sync_call);
         rc = pcmk_legacy2rc(rc);
 
         if (rc != pcmk_rc_ok) {
             exit_code = pcmk_rc2exitc(rc);
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code, "Could not get local CIB: %s",
                         pcmk_rc_str(rc));
             goto done;
         }
     }
 
     if (!cli_config_update(&cib_xml_copy, NULL, FALSE)) {
         exit_code = CRM_EX_CONFIG;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Could not update local CIB to latest schema version");
         goto done;
     }
 
     scheduler->input = cib_xml_copy;
     scheduler->now = crm_time_new(NULL);
 
     cluster_status(scheduler);
 
     /* For recording the tickets that are referenced in PCMK_XE_RSC_TICKET
      * constraints but have never been granted yet.
      */
     pcmk__unpack_constraints(scheduler);
 
     if (options.ticket_cmd == 'l' || options.ticket_cmd == 'L' || options.ticket_cmd == 'w') {
         bool raw = false;
         bool details = false;
 
         if (options.ticket_cmd == 'L') {
             details = true;
         } else if (options.ticket_cmd == 'w') {
             raw = true;
         }
 
         rc = pcmk__ticket_info(out, scheduler, options.ticket_id, details, raw);
         exit_code = pcmk_rc2exitc(rc);
 
         if (rc == ENXIO) {
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "No such ticket '%s'", options.ticket_id);
         } else if (rc != pcmk_rc_ok) {
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Could not get ticket info: %s", pcmk_rc_str(rc));
         }
 
     } else if (options.ticket_cmd == 'q') {
         rc = pcmk__ticket_state(out, cib_conn, options.ticket_id);
 
         if (rc != pcmk_rc_ok && rc != pcmk_rc_duplicate_id) {
             exit_code = pcmk_rc2exitc(rc);
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Could not query ticket XML: %s", pcmk_rc_str(rc));
         } else {
             exit_code = CRM_EX_OK;
         }
 
     } else if (options.ticket_cmd == 'c') {
         rc = pcmk__ticket_constraints(out, cib_conn, options.ticket_id);
         exit_code = pcmk_rc2exitc(rc);
 
         if (rc != pcmk_rc_ok) {
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Could not show ticket constraints: %s", pcmk_rc_str(rc));
         }
 
     } else if (options.ticket_cmd == 'G') {
         if (options.ticket_id == NULL) {
             exit_code = CRM_EX_NOSUCH;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply ticket ID with -t");
             goto done;
         }
 
         rc = pcmk__ticket_get_attr(out, scheduler, options.ticket_id,
                                    options.get_attr_name, options.attr_default);
         exit_code = pcmk_rc2exitc(rc);
 
     } else if (options.ticket_cmd == 'C') {
         if (options.ticket_id == NULL) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply ticket ID with -t");
             goto done;
         }
 
-        if (options.force == FALSE) {
-            pcmk_ticket_t *ticket = NULL;
+        rc = pcmk__ticket_delete(out, cib_conn, scheduler, options.ticket_id,
+                                 options.force);
+        exit_code = pcmk_rc2exitc(rc);
 
-            ticket = find_ticket(options.ticket_id, scheduler);
-            if (ticket == NULL) {
-                exit_code = CRM_EX_NOSUCH;
+        switch (rc) {
+            case ENXIO:
                 g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                             "No such ticket '%s'", options.ticket_id);
-                goto done;
-            }
+                break;
 
-            if (ticket->granted) {
+            case EACCES:
                 ticket_revoke_warning(options.ticket_id);
-                exit_code = CRM_EX_INSUFFICIENT_PRIV;
-                goto done;
-            }
-        }
+                break;
 
-        rc = delete_ticket_state(options.ticket_id, cib_conn);
-        exit_code = pcmk_rc2exitc(rc);
+            case pcmk_rc_ok:
+            case pcmk_rc_duplicate_id:
+                break;
 
-        if (rc != pcmk_rc_ok) {
-            g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
-                        "Could not clean up ticket: %s", pcmk_rc_str(rc));
+            default:
+                g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
+                            "Could not clean up ticket: %s", pcmk_rc_str(rc));
+                break;
         }
 
     } else if (modified) {
         if (options.ticket_id == NULL) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply ticket ID with -t");
             goto done;
         }
 
         if (options.attr_value
             && (pcmk__str_empty(options.attr_name))) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply attribute name with -S for -v %s", options.attr_value);
             goto done;
         }
 
         if (options.attr_name
             && (pcmk__str_empty(options.attr_value))) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply attribute value with -v for -S %s", options.attr_value);
             goto done;
         }
 
         if (!allow_modification(options.ticket_id)) {
             exit_code = CRM_EX_INSUFFICIENT_PRIV;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Ticket modification not allowed");
             goto done;
         }
 
         rc = modify_ticket_state(options.ticket_id, cib_conn, scheduler);
         exit_code = pcmk_rc2exitc(rc);
 
         if (rc != pcmk_rc_ok) {
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Could not modify ticket: %s", pcmk_rc_str(rc));
         }
 
     } else if (options.ticket_cmd == 'S') {
         /* Correct usage was handled in the "if (modified)" block above, so
          * this is just for reporting usage errors
          */
 
         if (pcmk__str_empty(options.attr_name)) {
             // We only get here if ticket_cmd was left as default
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code, "Must supply a command");
             goto done;
         }
 
         if (options.ticket_id == NULL) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply ticket ID with -t");
             goto done;
         }
 
         if (pcmk__str_empty(options.attr_value)) {
             exit_code = CRM_EX_USAGE;
             g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                         "Must supply value with -v for -S %s", options.attr_name);
             goto done;
         }
 
     } else {
         exit_code = CRM_EX_USAGE;
         g_set_error(&error, PCMK__EXITC_ERROR, exit_code,
                     "Unknown command: %c", options.ticket_cmd);
     }
 
  done:
     if (attr_set) {
         g_hash_table_destroy(attr_set);
     }
     attr_set = NULL;
 
     if (attr_delete) {
         g_list_free_full(attr_delete, free);
     }
     attr_delete = NULL;
 
     pe_free_working_set(scheduler);
     scheduler = NULL;
 
     cib__clean_up_connection(&cib_conn);
 
     g_strfreev(processed_args);
     pcmk__free_arg_context(context);
     g_free(options.attr_default);
     g_free(options.attr_id);
     free(options.attr_name);
     free(options.attr_value);
     free(options.get_attr_name);
     g_free(options.set_name);
     g_free(options.ticket_id);
     g_free(options.xml_file);
 
     pcmk__output_and_clear_error(&error, out);
 
     if (out != NULL) {
         out->finish(out, exit_code, true, NULL);
         pcmk__output_free(out);
     }
 
     pcmk__unregister_formats();
     crm_exit(exit_code);
 }