<?php
/**
 * PK Core - shared WordPress Abilities API registration helper.
 *
 * WHY THIS EXISTS
 * Eighteen PublisherKits plugins run on the estate and not one registers an
 * ability, while Jetpack, WooCommerce, Yoast, Gravity Forms and WP Rocket
 * already do. The goal is that staff reach PublisherKits data from their own
 * AI without learning eight settings screens.
 *
 * Eight plugins bundle this core. Without a shared helper they would invent
 * eight permission mappings, eight response shapes and eight availability
 * gates - and the estate would discover the differences one incident at a
 * time. Everything here exists so there is exactly one of each.
 *
 * THE FOUR GUARANTEES
 *
 * 1. NARROW PERMISSIONS, SEPARATELY ADDRESSABLE. An ability declares a TIER,
 *    not a capability. Two tiers may map to the same capability today and
 *    still be widened independently tomorrow, which is the thing a single
 *    manage_options gate makes impossible.
 *
 * 2. AN AVAILABILITY GATE IN ONE PLACE. The Abilities API is WordPress 6.9.
 *    Three bundlers are live on WordPress.org and must not hard-require it.
 *    Plugins never call wp_register_ability() and never test for it; they
 *    hook `pkcore_register_abilities`, which only ever fires on a site that
 *    has the API. On WP < 6.9 the hook does not fire, the callbacks do not
 *    run, and nothing fatals.
 *
 * 3. A PROVENANCE ENVELOPE, WITH ABSENT SEPARATED FROM ZERO. Four statuses,
 *    and the distinction between two of them is the whole point:
 *      ok           - we looked; here is the figure.
 *      empty        - we looked; there is genuinely nothing. data is 0 / [].
 *      partial      - we looked; the underlying data is known incomplete.
 *      unavailable  - WE COULD NOT LOOK. data is null, never 0, and a reason
 *                     is mandatory.
 *    An agent explains a wrong number fluently and confidently. "We could not
 *    look" reported as zero is how a quota gate, a dead cron or a missing
 *    table becomes a business conclusion.
 *
 * 4. READ-ONLY. Nothing registered through this helper writes. Consent,
 *    deletions, merges and company-identity decisions escalate to a human,
 *    and a caller that declares itself non-readonly is REFUSED registration
 *    rather than quietly allowed.
 *
 * THE SILENT FAILURE THIS HELPER IS BUILT AROUND
 * Every failure path in WP_Abilities_Registry::register() calls
 * _doing_it_wrong() and returns null. _doing_it_wrong() prints nothing unless
 * WP_DEBUG is on, so in production a rejected registration is INVISIBLE: a bad
 * name, a duplicate, an unregistered category or an invalid schema all leave a
 * site that looks fully wired and answers nothing. This helper therefore
 * checks the return value of every registration, records what failed and why,
 * and exposes it through failures() so the miss is countable rather than
 * absent. Registering is the attempt; failures() is the effect.
 *
 * @package PublisherKits\Core
 * @since   0.9.11
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

if ( ! class_exists( 'PKCORE_Abilities' ) ) {

	/**
	 * Class PKCORE_Abilities
	 *
	 * @since 0.9.11
	 */
	class PKCORE_Abilities {

		/**
		 * Contract version of THIS helper. Increments only when public
		 * methods, argument shapes or envelope keys are ADDED.
		 *
		 * WHY A VERSION AND NOT JUST class_exists()
		 * class_exists( 'PKCORE_Abilities' ) is necessary and NOT sufficient,
		 * because pkcore is newest-wins. Consider:
		 *
		 *   Plugin A bundles core 0.12 (helper v2, with a new argument).
		 *   Plugin B bundles core 0.11 (helper v1).
		 *   Both active  -> 0.12 boots -> both get v2. Fine.
		 *   ONLY B active -> 0.11 boots. B's code was written against v2.
		 *                    class_exists() PASSES. The signature is v1.
		 *                    Silent no-op or fatal, depending which way it
		 *                    differs.
		 *
		 * So every call site gates on existence AND version:
		 *
		 *     if ( class_exists( 'PKCORE_Abilities' )
		 *         && PKCORE_Abilities::contract_version() >= 2 ) { ... }
		 *
		 * Shipped at v1 deliberately. Retrofitting a version onto a contract
		 * already vendored into eight bundlers is not possible without a flag
		 * day, so it existed from the first release whether or not anything
		 * needed it yet.
		 *
		 * V1 -> V2 WAS A BREAKING CHANGE, AND THE ONLY ONE THERE WILL EVER BE.
		 * At 0.9.13 stamp() gained a REQUIRED argument, which is a break, not
		 * an addition. It was permissible for exactly one reason, verified
		 * rather than assumed at the time: `grep -rln "PKCORE_Abilities::"`
		 * outside publisherkits-core returned NOTHING - zero external callers,
		 * hours after first release and before tranche 1 began. That window
		 * shuts the moment a plugin registers its first ability and never
		 * reopens.
		 *
		 * The version had to move even though nothing consumed it, for two
		 * reasons. The weaker one is ambiguity: leaving it at 1 would make
		 * "contract_version 1" denote two different signatures, which is
		 * precisely what this method exists to prevent.
		 *
		 * The stronger one is ARCHAEOLOGY. v1 briefly existed in the wild and
		 * still does:
		 *
		 *   v1  core 0.9.11 and 0.9.12 - pushed to all nine remotes 27/28 Aug
		 *       2026. Its stamp() signature was `stamp( $timestamp = null )`,
		 *       and calling it with no argument returned the CALL time, so a
		 *       cached figure could be stamped as freshly measured.
		 *   v2  core 0.9.13 only. `stamp( $timestamp )` - argument required.
		 *   v3  core 0.9.14 onward. ADDITIVE: the optional `mcp_public`
		 *       argument, and `meta['mcp']` set on every registration so
		 *       abilities are discoverable by MCP clients. A v2 helper ignores
		 *       `mcp_public` silently and registers nothing discoverable.
		 *
		 * DO NOT RAISE YOUR REGISTRATION FLOOR WHEN THIS NUMBER MOVES. That is
		 * the obvious reading of a bump and it is wrong here, expensively so.
		 * On a v2 core the abilities still register, still answer, and are
		 * still reachable over the REST route - they are merely undiscoverable.
		 * A plugin gating registration on `>= 3` would turn that degraded but
		 * useful state into NO ABILITIES AT ALL. Tranche 1 gates registration
		 * at `>= 2` deliberately and should stay there.
		 *
		 * The general rule, which will apply to every future bump on this
		 * contract: DISCOVERABILITY AND ENHANCEMENT ARE FEATURES TO DETECT,
		 * NOT REQUIREMENTS TO DEMAND. Gate registration on the lowest version
		 * that makes the ability WORK, and probe supports() for anything you
		 * want to report on or branch over. Raise the floor only for a version
		 * whose absence makes the ability wrong, not merely poorer - which,
		 * given the additive-only rule now binding, should be never.
		 *
		 * Under newest-wins an ability written against one of those bundles can
		 * run under the other, so months from now someone will need to ask
		 * which shape they are holding. If 0.9.13 had also reported 1 there
		 * would be no way to answer. That is the entire job of this method, and
		 * bumping cost nothing on the day.
		 *
		 * FROM HERE THE RULE BINDS: additive only. New capability = a new
		 * method or a new OPTIONAL argument. A breaking shape = a new _v2
		 * method. Same discipline as PKCORE_Loader and the six other
		 * contract_version() surfaces in the estate.
		 *
		 * @since 0.9.11
		 * @since 0.9.13 v2 - stamp() now requires its argument.
		 * @since 0.9.14 v3 - additive: mcp_public, and meta['mcp'] on every registration.
		 * @return int
		 */
		public static function contract_version() {
			return 3;
		}

		/**
		 * Whether this build of the helper supports a named feature.
		 *
		 * Finer-grained than contract_version(), and the better gate when a
		 * caller needs one specific capability rather than a whole version:
		 * a plugin can ask for what it actually uses instead of demanding a
		 * floor that may be higher than it needs. Mirrors PKCI_Read::supports().
		 *
		 * Unknown features answer FALSE, never a fatal - an older helper must
		 * be able to answer a question invented after it shipped.
		 *
		 * @since 0.9.11
		 * @param string $feature Feature slug.
		 * @return bool
		 */
		public static function supports( $feature ) {
			$features = array(
				// The four-status provenance envelope.
				'envelope'         => true,
				// Machine-readable caveat codes on the envelope.
				'caveats'          => true,
				// Named permission tiers with a widening filter.
				'permission_tiers' => true,
				// Countable registration failures.
				'failures'         => true,
				// meta['mcp'] set, so abilities are discoverable by MCP
				// clients rather than merely reachable over REST. The
				// distinction is invisible at registration time: a helper
				// without this registers successfully and is never listed.
				'mcp_discovery'    => true,
			);
			return ! empty( $features[ (string) $feature ] );
		}

		/*
		 * ---- Well-known caveat codes ----------------------------------
		 *
		 * An agent must be able to branch on a CODE without parsing English.
		 * Prose is for the human reading the transcript afterwards; the code
		 * is what decides whether a figure may be quoted. These four are the
		 * ones the estate already knows it needs; the list is open, and a
		 * consumer may pass any snake_case code it documents.
		 */

		/**
		 * The underlying figures are known to be untrustworthy and must not be
		 * rendered as a real zero.
		 */
		const CAVEAT_STATS_INCOMPLETE = 'stats_incomplete';

		/**
		 * The figures are FINAL - frozen before the detail behind them was
		 * pruned. Not stale; not recomputable. Do not offer to refresh them.
		 */
		const CAVEAT_STATS_FROZEN = 'stats_frozen';

		/**
		 * The window asked about is older than this site's retention, so the
		 * detail no longer exists. Absence here is not evidence of nothing.
		 */
		const CAVEAT_OUTSIDE_RETENTION = 'outside_retention';

		/**
		 * The figure mixes streams (or sources) that are not comparable.
		 */
		const CAVEAT_MIXED_STREAM = 'mixed_stream';

		/**
		 * Ability namespace. Ability names are "<NAMESPACE>/<slug>".
		 *
		 * WordPress validates names against /^[a-z0-9-]+\/[a-z0-9-]+$/ - lower
		 * case alphanumerics and dashes only, exactly one slash, NO
		 * UNDERSCORES. A name that breaks it is rejected with a silent
		 * _doing_it_wrong(), so validate_name() checks it here first.
		 */
		const NAMESPACE_SLUG = 'publisherkits';

		/**
		 * The shared ability category slug.
		 *
		 * Categories must be registered on wp_abilities_api_categories_init,
		 * which fires BEFORE wp_abilities_api_init. An ability naming an
		 * unregistered category is rejected and returns null.
		 */
		const CATEGORY = 'publisherkits';

		/* ---- Envelope statuses ------------------------------------------ */

		/**
		 * We looked and here is the figure.
		 */
		const STATUS_OK = 'ok';

		/**
		 * We looked and there is genuinely nothing.
		 */
		const STATUS_EMPTY = 'empty';

		/**
		 * We looked, but the underlying data is known to be incomplete.
		 */
		const STATUS_PARTIAL = 'partial';

		/**
		 * WE COULD NOT LOOK. Never the same as empty.
		 */
		const STATUS_UNAVAILABLE = 'unavailable';

		/**
		 * Abilities registered successfully this request: name => tier.
		 *
		 * @var array<string,string>
		 */
		private static $registered = array();

		/**
		 * Registrations that FAILED this request: name => reason.
		 *
		 * The whole point of the class. See the docblock.
		 *
		 * @var array<string,string>
		 */
		private static $failures = array();

		/**
		 * Extra categories requested by plugins: slug => args.
		 *
		 * @var array<string,array>
		 */
		private static $categories = array();

		/**
		 * Whether init() has run.
		 *
		 * @var bool
		 */
		private static $initialised = false;

		/**
		 * Wire the registration dispatch.
		 *
		 * Deliberately NOT gated on is_admin(): abilities are consumed over
		 * REST and by AI clients, where is_admin() is false. Gating this the
		 * way the admin surface is gated would produce a helper that works in
		 * wp-admin and answers nothing to the callers it exists for.
		 *
		 * Safe to call on any WordPress version. On WP < 6.9 the two hooks
		 * below simply never fire.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function init() {
			if ( self::$initialised ) {
				return;
			}
			self::$initialised = true;

			// add_action() on a hook WordPress will never fire is harmless, so
			// these are registered unconditionally rather than behind the
			// availability gate. That keeps the gate in exactly one place
			// (available(), consulted at dispatch) instead of two that can
			// drift apart.
			add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'dispatch_categories' ) );
			add_action( 'wp_abilities_api_init', array( __CLASS__, 'dispatch_abilities' ) );

			// THE CONSUMER OF failures(). Without it this class records every
			// rejection carefully and shows nobody, which is the same
			// invisible failure it was written to fix, one level up.
			add_filter( 'site_status_tests', array( __CLASS__, 'register_site_health_test' ) );
		}

		/**
		 * Add the PublisherKits abilities check to Site Health.
		 *
		 * @since 0.9.11
		 * @param array $tests Site Health tests.
		 * @return array
		 */
		public static function register_site_health_test( $tests ) {
			if ( ! is_array( $tests ) ) {
				return $tests;
			}
			$tests['direct']['pkcore_abilities'] = array(
				'label' => __( 'PublisherKits AI abilities', 'publisherkits-socialpromoter' ),
				'test'  => array( __CLASS__, 'site_health_test' ),
			);
			return $tests;
		}

		/**
		 * Report whether the abilities registered, and say so distinguishably.
		 *
		 * FOUR OUTCOMES, and conflating any two of them would hide a real
		 * fault behind a plausible one:
		 *
		 *   API absent        WordPress is below 6.9. Not a fault. Informational.
		 *   registrations OK  Abilities are live. Good.
		 *   zero registered   The API is here and nothing registered. A FAULT,
		 *                     and the one that is otherwise invisible.
		 *   failures recorded WordPress rejected specific registrations, with
		 *                     reasons. A FAULT, with the detail attached.
		 *
		 * @since 0.9.11
		 * @return array Site Health result.
		 */
		public static function site_health_test() {
			$result = array(
				'label'       => __( 'PublisherKits AI abilities are registered', 'publisherkits-socialpromoter' ),
				'status'      => 'good',
				'badge'       => array(
					'label' => __( 'PublisherKits', 'publisherkits-socialpromoter' ),
					'color' => 'blue',
				),
				'description' => '',
				'test'        => 'pkcore_abilities',
			);

			if ( ! self::available() ) {
				$result['status']      = 'recommended';
				$result['label']       = __( 'This WordPress version has no Abilities API', 'publisherkits-socialpromoter' );
				$result['description'] = '<p>' . esc_html__( 'The WordPress Abilities API arrived in WordPress 6.9. PublisherKits plugins work normally without it; AI assistants simply cannot query your publishing data until you upgrade. Nothing is broken.', 'publisherkits-socialpromoter' ) . '</p>';
				return $result;
			}

			// FORCE THE REGISTRY TO INITIALISE BEFORE READING THE RESULT.
			//
			// The abilities registry initialises lazily, so on a plain admin
			// page load wp_abilities_api_init may never have fired. Reading
			// failures() without this would report an empty list because
			// nothing had run yet - "we could not look" presented as "we
			// looked and found nothing", in the very check written to stop
			// that happening.
			if ( function_exists( 'wp_get_abilities' ) ) {
				wp_get_abilities();
			}

			$failures   = self::failures();
			$registered = self::registered();

			if ( ! empty( $failures ) ) {
				$items = '';
				foreach ( $failures as $name => $reason ) {
					$items .= '<li><code>' . esc_html( $name ) . '</code> - ' . esc_html( $reason ) . '</li>';
				}
				$result['status']      = 'critical';
				$result['label']       = __( 'Some PublisherKits abilities could not be registered', 'publisherkits-socialpromoter' );
				$result['description'] = '<p>' . esc_html(
					sprintf(
						/* translators: %d: number of failed registrations. */
						_n(
							'%d PublisherKits ability was rejected by WordPress and is not available to AI assistants. WordPress reports these silently, so this check is the only place they appear.',
							'%d PublisherKits abilities were rejected by WordPress and are not available to AI assistants. WordPress reports these silently, so this check is the only place they appear.',
							count( $failures ),
							'publisherkits-socialpromoter'
						),
						count( $failures )
					)
				) . '</p><ul>' . $items . '</ul>';
				return $result;
			}

			if ( empty( $registered ) ) {
				// The API is present and PublisherKits registered nothing. On
				// a site with the core booted this should be impossible, so
				// treat it as a fault rather than as "nothing to report".
				$result['status']      = 'recommended';
				$result['label']       = __( 'No PublisherKits abilities are registered', 'publisherkits-socialpromoter' );
				$result['description'] = '<p>' . esc_html__( 'This site has the WordPress Abilities API but no PublisherKits ability registered with it, so AI assistants cannot read your publishing data. This is unexpected - it usually means the PublisherKits Core library did not finish loading.', 'publisherkits-socialpromoter' ) . '</p>';
				return $result;
			}

			$result['description'] = '<p>' . esc_html(
				sprintf(
					/* translators: %d: number of registered abilities. */
					_n(
						'%d PublisherKits ability is available to AI assistants on this site.',
						'%d PublisherKits abilities are available to AI assistants on this site.',
						count( $registered ),
						'publisherkits-socialpromoter'
					),
					count( $registered )
				)
			) . '</p>';
			return $result;
		}

		/**
		 * Whether the Abilities API is present on this site.
		 *
		 * THE GATE. Filterable so the unavailable path can be exercised
		 * deliberately - a guard that has never fired proves nothing, and
		 * tests/test-abilities.php drives both directions through this.
		 *
		 * @since 0.9.11
		 * @return bool
		 */
		public static function available() {
			$available = function_exists( 'wp_register_ability' )
				&& function_exists( 'wp_register_ability_category' )
				&& function_exists( 'wp_has_ability_category' );

			/**
			 * Filters whether the Abilities API is considered available.
			 *
			 * Can only ever turn availability OFF: a site cannot conjure the
			 * API by filtering, and pretending otherwise would move a clean
			 * no-op into a fatal.
			 *
			 * @since 0.9.11
			 * @param bool $available Whether the API is present.
			 */
			return $available && (bool) apply_filters( 'pkcore_abilities_available', $available );
		}

		/**
		 * Permission tiers, narrowest first.
		 *
		 * NOT one gate for everything. `content` and `audience` share
		 * edit_posts today and `commercial`, `settings` and `infrastructure`
		 * share manage_options, but they are FIVE addressable tiers: a site
		 * can grant an editor commercial reporting without granting them
		 * settings, through the pkcore_ability_capability filter. Collapsing
		 * them to one capability is what makes per-user control impossible.
		 *
		 * @since 0.9.11
		 * @return array<string,string> tier => capability.
		 */
		public static function tiers() {
			return array(
				// Facts about posts, topics and classification.
				'content'        => 'edit_posts',
				// Engagement and performance data about published content.
				'audience'       => 'edit_posts',
				// Revenue, advertisers, subscribers. Sensitive, and separable.
				'commercial'     => 'manage_options',
				// Plugin configuration.
				'settings'       => 'manage_options',
				// Cron health, connectors, delivery infrastructure.
				'infrastructure' => 'manage_options',
			);
		}

		/**
		 * The capability an ability tier requires.
		 *
		 * @since 0.9.11
		 * @param string $tier    Tier slug.
		 * @param string $ability Ability name, for the filter's benefit.
		 * @return string Capability, or '' when the tier is unknown.
		 */
		public static function capability_for( $tier, $ability = '' ) {
			$tiers = self::tiers();
			$tier  = (string) $tier;
			if ( ! isset( $tiers[ $tier ] ) ) {
				return '';
			}

			/**
			 * Filters the capability required by an ability tier.
			 *
			 * This is the per-user control surface: a site can widen one tier
			 * without widening the rest.
			 *
			 * @since 0.9.11
			 * @param string $capability The default capability.
			 * @param string $tier       Tier slug.
			 * @param string $ability    Ability name.
			 */
			$capability = apply_filters( 'pkcore_ability_capability', $tiers[ $tier ], $tier, (string) $ability );

			return is_string( $capability ) && '' !== $capability ? $capability : $tiers[ $tier ];
		}

		/*
		 * ----------------------------------------------------------------
		 * Dispatch
		 * ----------------------------------------------------------------
		 */

		/**
		 * Fire the category-registration action.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function dispatch_categories() {
			if ( ! self::available() ) {
				return;
			}

			self::register_category(
				self::CATEGORY,
				array(
					'label'       => __( 'PublisherKits', 'publisherkits-socialpromoter' ),
					'description' => __( 'Read-only publishing, audience and performance data from the PublisherKits suite.', 'publisherkits-socialpromoter' ),
				)
			);

			/**
			 * Register additional ability categories.
			 *
			 * Fires only on a site with the Abilities API, so a callback may
			 * call PKCORE_Abilities::register_category() without its own
			 * version check.
			 *
			 * @since 0.9.11
			 */
			do_action( 'pkcore_register_ability_categories' );
		}

		/**
		 * Fire the ability-registration action.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function dispatch_abilities() {
			if ( ! self::available() ) {
				return;
			}

			/**
			 * Register PublisherKits abilities.
			 *
			 * THE HOOK EVERY BUNDLING PLUGIN USES. It fires only on a site
			 * that has the Abilities API, so a callback may call
			 * PKCORE_Abilities::register() directly.
			 *
			 * Newest-wins means the booted core is never older than any
			 * bundled copy, so a core that fires this hook always has this
			 * class. A call site reaching PKCORE_Abilities from anywhere ELSE
			 * must still check class_exists() first - a plugin does not
			 * necessarily run its own bundled core.
			 *
			 * @since 0.9.11
			 */
			do_action( 'pkcore_register_abilities' );
		}

		/*
		 * ----------------------------------------------------------------
		 * Registration
		 * ----------------------------------------------------------------
		 */

		/**
		 * Register an ability category, once.
		 *
		 * Idempotent: eight plugins may each want the shared category and only
		 * the first registration can succeed - a repeat is rejected with a
		 * silent _doing_it_wrong(), which would otherwise be recorded as a
		 * failure and read as a fault.
		 *
		 * @since 0.9.11
		 * @param string $slug Category slug.
		 * @param array  $args label + description.
		 * @return bool True when the category exists afterwards.
		 */
		public static function register_category( $slug, $args = array() ) {
			if ( ! self::available() ) {
				return false;
			}

			$slug = (string) $slug;
			if ( ! preg_match( '/^[a-z0-9-]+$/', $slug ) ) {
				self::record_failure( $slug, 'invalid category slug (lowercase alphanumerics and dashes only)' );
				return false;
			}

			if ( wp_has_ability_category( $slug ) ) {
				self::$categories[ $slug ] = true;
				return true; // Someone got there first; not a fault.
			}

			$category = wp_register_ability_category(
				$slug,
				array(
					'label'       => isset( $args['label'] ) ? (string) $args['label'] : $slug,
					'description' => isset( $args['description'] ) ? (string) $args['description'] : '',
				)
			);

			if ( null === $category ) {
				self::record_failure( $slug, 'wp_register_ability_category() returned null' );
				return false;
			}

			self::$categories[ $slug ] = true;
			return true;
		}

		/**
		 * Register a read-only ability with a permission tier and a provenance
		 * envelope.
		 *
		 * Accepts a SIMPLIFIED argument set and builds the WordPress one, so a
		 * plugin cannot accidentally register something admin-gated, writable
		 * or provenance-free:
		 *
		 *   name         string   Slug only ("delivery-summary") or a fully
		 *                         namespaced name ("publisherkits/x").
		 *   label        string   Required. Human-readable.
		 *   description  string   Required. What it answers, in a sentence.
		 *   tier         string   Required. One of tiers().
		 *   callback     callable Required. Receives $input, MUST return an
		 *                         envelope from ok()/empty_result()/partial()/
		 *                         unavailable(), or a WP_Error.
		 *   data_schema  array    Optional. JSON Schema for the `data` member.
		 *   input_schema array    Optional. JSON Schema for the input.
		 *   permission_extra callable Optional. An ADDITIONAL predicate; it can
		 *                         only narrow access, never widen it.
		 *   category     string   Optional. Defaults to CATEGORY.
		 *   show_in_rest bool     Optional. Default true.
		 *   mcp_public   bool     Optional. Default true. Whether the ability is
		 *                         DISCOVERABLE by MCP clients. Not authorisation -
		 *                         permission_callback still gates execution, so an
		 *                         ability appearing in a tool list is not a user
		 *                         being able to run it.
		 *                         Honoured from contract_version() >= 3; an older
		 *                         helper ignores it silently. Probe it with
		 *                         supports( 'mcp_discovery' ) - do NOT raise your
		 *                         registration floor to 3 for it (see
		 *                         contract_version()'s note: on a v2 core the
		 *                         ability still works, it is only undiscoverable,
		 *                         and refusing to register is strictly worse).
		 *
		 * @since 0.9.11
		 * @param string $name Ability slug or full name.
		 * @param array  $args See above.
		 * @return bool True only when WordPress actually registered it.
		 */
		public static function register( $name, $args ) {
			$name = self::qualify( $name );

			if ( ! self::available() ) {
				// Not a failure. The site does not have the API; the correct
				// behaviour is to do nothing, quietly and without fatal.
				return false;
			}

			$args = is_array( $args ) ? $args : array();

			$invalid = self::validate_name( $name );
			if ( '' !== $invalid ) {
				self::record_failure( $name, $invalid );
				return false;
			}

			foreach ( array( 'label', 'description', 'tier', 'callback' ) as $required ) {
				if ( ! isset( $args[ $required ] ) || '' === $args[ $required ] ) {
					self::record_failure( $name, sprintf( 'missing required argument "%s"', $required ) );
					return false;
				}
			}

			if ( ! is_callable( $args['callback'] ) ) {
				self::record_failure( $name, 'callback is not callable' );
				return false;
			}

			// READ-ONLY IS NOT NEGOTIABLE IN THIS SCOPE. A caller that asks for
			// a writable ability is refused rather than silently downgraded, so
			// the mistake surfaces where it is made.
			if ( isset( $args['readonly'] ) && false === $args['readonly'] ) {
				self::record_failure( $name, 'this helper registers read-only abilities only; writes escalate to a human' );
				return false;
			}

			$tier       = (string) $args['tier'];
			$capability = self::capability_for( $tier, $name );
			if ( '' === $capability ) {
				// No silent default. Defaulting to manage_options would hide
				// the mistake behind an over-tight gate; defaulting to
				// anything looser would leak. Refuse.
				self::record_failure( $name, sprintf( 'unknown permission tier "%s" (expected one of: %s)', $tier, implode( ', ', array_keys( self::tiers() ) ) ) );
				return false;
			}

			$category = isset( $args['category'] ) ? (string) $args['category'] : self::CATEGORY;
			if ( ! wp_has_ability_category( $category ) ) {
				// Would be rejected with an invisible _doing_it_wrong(). Say so.
				self::record_failure( $name, sprintf( 'ability category "%s" is not registered (register it on pkcore_register_ability_categories)', $category ) );
				return false;
			}

			$callback = $args['callback'];
			$extra    = isset( $args['permission_extra'] ) && is_callable( $args['permission_extra'] ) ? $args['permission_extra'] : null;

			$registration = array(
				'label'               => (string) $args['label'],
				'description'         => (string) $args['description'],
				'category'            => $category,
				'permission_callback' => static function ( $input = null ) use ( $capability, $extra ) {
					if ( ! current_user_can( $capability ) ) {
						return false;
					}
					if ( null === $extra ) {
						return true;
					}
					// AND, never OR: an extra predicate narrows and cannot widen.
					$allowed = call_user_func( $extra, $input );
					if ( is_wp_error( $allowed ) ) {
						return $allowed;
					}
					return (bool) $allowed;
				},
				'execute_callback'    => static function ( $input = null ) use ( $callback, $name ) {
					return self::run( $callback, $input, $name );
				},
				'output_schema'       => self::envelope_schema( isset( $args['data_schema'] ) ? $args['data_schema'] : array() ),
				'meta'                => array(
					'annotations'  => array(
						'readonly'    => true,
						'destructive' => false,
						'idempotent'  => true,
					),
					'show_in_rest' => ! isset( $args['show_in_rest'] ) || (bool) $args['show_in_rest'],

					// MCP DISCOVERY. Added at 0.9.14, and without it every
					// ability here is invisible to the only client the staff
					// actually use.
					//
					// `mcp` is NOT part of the WordPress Abilities API. Core
					// knows exactly two meta keys - `annotations` and
					// `show_in_rest` (verified in WP_Ability's own docblock) -
					// and `meta` is otherwise free-form. So an ability without
					// `mcp` is completely correct against WordPress and still
					// undiscoverable in practice.
					//
					// It is Novamira's MCP adapter convention. Verified on a live
					// estate site in novamira/includes/abilities/
					// discover-abilities.php, which is the tool an AI client
					// calls to find out what it can do:
					//
					// foreach ( wp_get_abilities() as $ability ) {
					// $meta = $ability->get_meta();
					// if ( ! ( $meta['mcp']['public'] ?? false ) )      { continue; }
					// if ( ( $meta['mcp']['type'] ?? 'tool' ) !== 'tool' ) { continue; }
					//
					// Two silent `continue`s. Nothing errors, nothing is
					// logged, failures() stays empty, and the REST route keeps
					// answering correctly - the ability simply never appears in
					// the tool list. Measured before this fix: discovery
					// returned 92 abilities, all Novamira's own, and neither
					// publisherkits/site-profile nor WordPress core's own
					// core/get-site-info was among them.
					//
					// `type` is always 'tool': everything registered here is a
					// callable action returning data, not a static resource,
					// and the gate above rejects anything that is not 'tool'.
					'mcp'          => array(
						// Discoverability, NOT authorisation. permission_callback
						// still gates execution, so a commercial-tier ability
						// being listed does not mean an editor can run it.
						//
						// Defaults true because register() refuses a non-readonly
						// ability, so everything here is safe to advertise. The
						// opt-out exists anyway: the day that invariant is
						// relaxed, blanket-public becomes the wrong default, and
						// adding the argument then would be a change of
						// behaviour rather than a new option.
						'public' => ! isset( $args['mcp_public'] ) || (bool) $args['mcp_public'],
						'type'   => 'tool',
					),
				),
			);

			if ( isset( $args['input_schema'] ) && is_array( $args['input_schema'] ) && ! empty( $args['input_schema'] ) ) {
				$registration['input_schema'] = $args['input_schema'];
			}

			$ability = wp_register_ability( $name, $registration );

			// THE CHECK THAT MAKES THIS COUNTABLE. Every rejection inside
			// WordPress is a silent _doing_it_wrong() plus null, so without
			// this the estate would carry a site that looks wired and answers
			// nothing.
			if ( null === $ability ) {
				self::record_failure( $name, 'wp_register_ability() returned null (WordPress rejected the registration; enable WP_DEBUG for its reason)' );
				return false;
			}

			self::$registered[ $name ] = $tier;
			return true;
		}

		/**
		 * Run a consumer callback and refuse to invent provenance for it.
		 *
		 * A callback that returns a bare value is a PROGRAMMING ERROR and is
		 * reported as one. The tempting alternative - wrapping the value in an
		 * `ok` envelope - would stamp "we looked, here it is" on a figure
		 * nobody vouched for, which is precisely the failure the envelope
		 * exists to prevent.
		 *
		 * @since 0.9.11
		 * @param callable $callback Consumer callback.
		 * @param mixed    $input    Ability input.
		 * @param string   $name     Ability name.
		 * @return array|WP_Error
		 */
		private static function run( $callback, $input, $name ) {
			$result = call_user_func( $callback, $input );

			if ( is_wp_error( $result ) ) {
				return $result;
			}

			if ( ! self::is_envelope( $result ) ) {
				return new WP_Error(
					'pkcore_ability_no_provenance',
					sprintf(
						/* translators: %s: ability name. */
						__( 'The ability "%s" returned a value with no provenance. PublisherKits abilities must return PKCORE_Abilities::ok(), empty_result(), partial() or unavailable().', 'publisherkits-socialpromoter' ),
						$name
					),
					array( 'status' => 500 )
				);
			}

			return $result;
		}

		/**
		 * Whether a value is a well-formed provenance envelope.
		 *
		 * @since 0.9.11
		 * @param mixed $value Value.
		 * @return bool
		 */
		public static function is_envelope( $value ) {
			if ( ! is_array( $value ) || ! isset( $value['status'], $value['provenance'] ) ) {
				return false;
			}
			if ( ! array_key_exists( 'data', $value ) || ! is_array( $value['provenance'] ) ) {
				return false;
			}
			return in_array(
				$value['status'],
				array( self::STATUS_OK, self::STATUS_EMPTY, self::STATUS_PARTIAL, self::STATUS_UNAVAILABLE ),
				true
			);
		}

		/*
		 * ----------------------------------------------------------------
		 * The provenance envelope
		 * ----------------------------------------------------------------
		 */

		/**
		 * We looked, and here is the figure.
		 *
		 * @since 0.9.11
		 * @param mixed $data       The answer.
		 * @param array $provenance { source (required), measured_at, observed,
		 *                          expected, note }.
		 * @return array
		 */
		public static function ok( $data, $provenance = array() ) {
			return self::envelope( self::STATUS_OK, $data, $provenance, true );
		}

		/**
		 * We looked, and there is genuinely nothing.
		 *
		 * DISTINCT FROM unavailable(). This one means the query ran and the
		 * answer is legitimately zero or an empty list - a real measurement,
		 * safe to report and safe to act on.
		 *
		 * @since 0.9.11
		 * @param mixed $data       The empty value in its natural shape - 0,
		 *                          array(), '' - NOT null.
		 * @param array $provenance { source (required), measured_at, note }.
		 * @return array
		 */
		public static function empty_result( $data = array(), $provenance = array() ) {
			return self::envelope( self::STATUS_EMPTY, $data, $provenance, true );
		}

		/**
		 * We looked, but the underlying data is known to be incomplete.
		 *
		 * The denominator is mandatory. "Nineteen thousand rows" was six
		 * hundred and eighty-three decisions; a figure without its denominator
		 * is not a measurement.
		 *
		 * @since 0.9.11
		 * @param mixed    $data       The partial answer.
		 * @param int      $observed   How many units were actually covered.
		 * @param int|null $expected   How many there should have been; null
		 *                             when genuinely unknowable.
		 * @param array    $provenance { source (required), measured_at, note }.
		 * @return array
		 */
		public static function partial( $data, $observed, $expected, $provenance = array() ) {
			$provenance             = is_array( $provenance ) ? $provenance : array();
			$provenance['observed'] = (int) $observed;
			$provenance['expected'] = ( null === $expected ) ? null : (int) $expected;

			return self::envelope( self::STATUS_PARTIAL, $data, $provenance, false );
		}

		/**
		 * WE COULD NOT LOOK.
		 *
		 * A quota gate, a blocked provider, a missing table, a dead cron, an
		 * unconfigured connector. NOT a measurement, and never reportable as
		 * one.
		 *
		 * Takes NO data argument, structurally. That is deliberate: there is no
		 * way to call this and accidentally hand back a zero, because there is
		 * nowhere to put one. `data` is always null.
		 *
		 * @since 0.9.11
		 * @param string $reason     Why the look failed, in plain words. Required.
		 * @param array  $provenance { source, note }.
		 * @return array
		 */
		public static function unavailable( $reason, $provenance = array() ) {
			$provenance           = is_array( $provenance ) ? $provenance : array();
			$reason               = trim( (string) $reason );
			$provenance['reason'] = ( '' === $reason )
				? __( 'No reason given. This is a bug in the calling plugin: an unavailable result must say what could not be reached.', 'publisherkits-socialpromoter' )
				: $reason;

			return self::envelope( self::STATUS_UNAVAILABLE, null, $provenance, false );
		}

		/**
		 * Build an envelope.
		 *
		 * @since 0.9.11
		 * @param string $status     One of the STATUS_* constants.
		 * @param mixed  $data       Payload; forced to null when unavailable.
		 * @param array  $provenance Raw provenance.
		 * @param bool   $complete   Whether the underlying data was complete.
		 * @return array
		 */
		private static function envelope( $status, $data, $provenance, $complete ) {
			$provenance = is_array( $provenance ) ? $provenance : array();

			$measured_at = isset( $provenance['measured_at'] ) ? trim( (string) $provenance['measured_at'] ) : '';
			$age         = self::age_seconds( $measured_at );

			$source = isset( $provenance['source'] ) ? trim( (string) $provenance['source'] ) : '';
			if ( '' === $source ) {
				// Never silently blank. A figure whose origin nobody recorded
				// is exactly what this envelope exists to make visible.
				$source = __( 'unrecorded - the calling plugin did not state a source', 'publisherkits-socialpromoter' );
			}

			return array(
				'status'     => (string) $status,
				// STRUCTURAL GUARD: absent is null, never zero, whatever the
				// caller passed.
				'data'       => ( self::STATUS_UNAVAILABLE === $status ) ? null : $data,
				'provenance' => array(
					'source'                  => $source,
					// The contract the figure came THROUGH, so a consumer can
					// tell whether the shape it expects was the shape it got.
					'source_contract_version' => isset( $provenance['source_contract_version'] ) ? (int) $provenance['source_contract_version'] : null,
					'measured_at'             => $measured_at,
					'age_seconds'             => $age,
					'complete'                => ( self::STATUS_UNAVAILABLE === $status ) ? null : (bool) $complete,
					'observed'                => isset( $provenance['observed'] ) ? (int) $provenance['observed'] : null,
					'expected'                => ( isset( $provenance['expected'] ) && null !== $provenance['expected'] ) ? (int) $provenance['expected'] : null,
					'reason'                  => isset( $provenance['reason'] ) ? (string) $provenance['reason'] : '',
					// MACHINE-READABLE FIRST. An agent branches on `code`; the
					// message is for the human reading the transcript later.
					'caveats'                 => self::normalise_caveats( isset( $provenance['caveats'] ) ? $provenance['caveats'] : array() ),
					'note'                    => isset( $provenance['note'] ) ? (string) $provenance['note'] : '',
					'helper_contract_version' => self::contract_version(),
					'core_version'            => class_exists( 'PKCORE_Core' ) ? (string) PKCORE_Core::version() : '',
				),
			);
		}

		/**
		 * Normalise caveats to a list of { code, message }.
		 *
		 * Accepts a bare list of codes, a code => message map, or already-built
		 * pairs, because eight plugins will each reach for a different one and
		 * a caveat that gets dropped for being the wrong shape is worse than no
		 * caveat system at all.
		 *
		 * @since 0.9.11
		 * @param mixed $caveats Raw caveats.
		 * @return array<int,array{code:string,message:string}>
		 */
		private static function normalise_caveats( $caveats ) {
			if ( ! is_array( $caveats ) || empty( $caveats ) ) {
				return array();
			}

			$out = array();
			foreach ( $caveats as $key => $value ) {
				$code    = '';
				$message = '';

				if ( is_array( $value ) ) {
					$code    = isset( $value['code'] ) ? (string) $value['code'] : (string) $key;
					$message = isset( $value['message'] ) ? (string) $value['message'] : '';
				} elseif ( is_string( $key ) ) {
					$code    = $key;              // code => message.
					$message = (string) $value;
				} else {
					$code = (string) $value;      // bare list of codes.
				}

				$code = strtolower( preg_replace( '/[^A-Za-z0-9_]/', '', $code ) );
				if ( '' === $code ) {
					continue;
				}
				$out[] = array(
					'code'    => $code,
					'message' => trim( $message ),
				);
			}
			return $out;
		}

		/**
		 * Age of a measurement in seconds.
		 *
		 * Returns NULL rather than 0 when the timestamp is missing or
		 * unparseable. Zero means "measured this second", which is the single
		 * most misleading value available.
		 *
		 * @since 0.9.11
		 * @param string $measured_at ISO 8601 / strtotime-parseable timestamp.
		 * @return int|null
		 */
		private static function age_seconds( $measured_at ) {
			$measured_at = trim( (string) $measured_at );
			if ( '' === $measured_at ) {
				return null;
			}
			$stamp = strtotime( $measured_at );
			if ( false === $stamp ) {
				return null;
			}
			return max( 0, time() - $stamp );
		}

		/**
		 * A timestamp in the shape the envelope expects: when THE DATA was
		 * measured.
		 *
		 * UTC, always. The estate has already invented four hours of drift once
		 * by comparing a server shell time against a UTC database column.
		 *
		 * THE ARGUMENT IS REQUIRED, AND THAT IS THE POINT (changed 0.9.13).
		 * It used to default to now, which made the WRONG answer SHORTER than
		 * the right one:
		 *
		 *     stamp()                          // 7 chars, silently "now"
		 *     stamp( $row->stats_last_updated ) // the truth
		 *
		 * Engagement Signals is exactly where that bites: ps_campaigns carries
		 * cached_* columns with their own stats_last_updated, so the shorter
		 * call would report a cached figure as freshly measured - a lie told
		 * with a straight face, by omission, in the one field the envelope
		 * exists to keep honest.
		 *
		 * WHEN IT ACTUALLY FIRES, stated precisely: PHP raises
		 * ArgumentCountError at CALL time, not at parse time. So this catches
		 * the author the first time that ability runs - in tranche 1 that is
		 * during development, which is good enough - but it is NOT a
		 * compile-time guarantee and must not be written up as one.
		 *
		 * NO stamp_now() CONVENIENCE, DELIBERATELY. One was drafted and
		 * removed: it reintroduces the short wrong path under a new name. The
		 * legitimate live-computation case writes stamp( time() ) explicitly,
		 * and the friction IS the feature - `stamp( time() )` sitting next to
		 * a cached column read is something a reviewer questions, and a tidy
		 * `stamp_now()` is something a reviewer skims. This optimises for the
		 * reviewer's attention rather than the author's keystrokes, because
		 * the failure mode is a stale figure reported as fresh and nobody
		 * noticing for months.
		 *
		 * This mirrors unavailable(), which takes no data argument so a zero
		 * cannot be smuggled into "we could not look". Same principle applied
		 * consistently: make the wrong thing impossible to express, not merely
		 * discouraged.
		 *
		 * @since 0.9.11
		 * @since 0.9.13 $timestamp is required.
		 * @param int $timestamp Unix timestamp of THE MEASUREMENT. For a figure
		 *                       genuinely computed during this request, pass
		 *                       time() explicitly.
		 * @return string
		 */
		public static function stamp( $timestamp ) {
			return gmdate( 'Y-m-d\TH:i:s\Z', (int) $timestamp );
		}

		/**
		 * The JSON Schema for a full envelope wrapping the given data schema.
		 *
		 * `data` accepts null in EVERY case, because unavailable() always
		 * returns null there. A schema that forbade null would make the
		 * "we could not look" answer fail validation - turning the one result
		 * that must survive into an error.
		 *
		 * @since 0.9.11
		 * @param array $data_schema JSON Schema for the data member.
		 * @return array
		 */
		public static function envelope_schema( $data_schema = array() ) {
			$data_schema = is_array( $data_schema ) ? $data_schema : array();

			if ( empty( $data_schema ) ) {
				$data_schema = array(
					'description' => __( 'The answer. Null when the status is "unavailable".', 'publisherkits-socialpromoter' ),
				);
			}

			// Append 'null' to the declared type(s) rather than wrapping in
			// anyOf: WordPress validates a subset of JSON Schema draft 4, and
			// an array of type names is the shape it handles most predictably.
			$types = isset( $data_schema['type'] ) ? $data_schema['type'] : null;
			if ( is_string( $types ) ) {
				$types = array( $types );
			}
			if ( is_array( $types ) ) {
				if ( ! in_array( 'null', $types, true ) ) {
					$types[] = 'null';
				}
				$data_schema['type'] = array_values( $types );
			}

			return array(
				'type'                 => 'object',
				'description'          => __( 'A PublisherKits answer with its provenance. Read the status before the data: "unavailable" means the figure could not be obtained, and is NOT zero.', 'publisherkits-socialpromoter' ),
				'required'             => array( 'status', 'data', 'provenance' ),
				'additionalProperties' => false,
				'properties'           => array(
					'status'     => array(
						'type'        => 'string',
						'enum'        => array( self::STATUS_OK, self::STATUS_EMPTY, self::STATUS_PARTIAL, self::STATUS_UNAVAILABLE ),
						'description' => __( '"ok" - measured. "empty" - measured, and genuinely nothing. "partial" - measured, but incomplete; read observed against expected. "unavailable" - COULD NOT MEASURE; data is null and reason says why. Never report "unavailable" as zero.', 'publisherkits-socialpromoter' ),
					),
					'data'       => $data_schema,
					'provenance' => array(
						'type'                 => 'object',
						'description'          => __( 'Where the figure came from, how old it is, and whether it was complete.', 'publisherkits-socialpromoter' ),
						'additionalProperties' => false,
						'properties'           => array(
							'source'                  => array(
								'type'        => 'string',
								'description' => __( 'What was actually read - the table, connector or API this came from.', 'publisherkits-socialpromoter' ),
							),
							'source_contract_version' => array(
								'type'        => array( 'integer', 'null' ),
								'description' => __( 'Contract version of the read surface the figure came through. NULL when the source declares none.', 'publisherkits-socialpromoter' ),
							),
							'caveats'                 => array(
								'type'        => 'array',
								'description' => __( 'Machine-readable warnings. BRANCH ON `code`, not on the message. A caveat of "stats_incomplete" or "outside_retention" means the figure must not be quoted as a plain number.', 'publisherkits-socialpromoter' ),
								'items'       => array(
									'type'       => 'object',
									'properties' => array(
										'code'    => array(
											'type'        => 'string',
											'description' => __( 'Stable snake_case code, e.g. stats_incomplete, stats_frozen, outside_retention, mixed_stream.', 'publisherkits-socialpromoter' ),
										),
										'message' => array(
											'type'        => 'string',
											'description' => __( 'Human explanation of the caveat.', 'publisherkits-socialpromoter' ),
										),
									),
								),
							),
							'helper_contract_version' => array(
								'type'        => 'integer',
								'description' => __( 'Contract version of the PK Core abilities helper that built this envelope.', 'publisherkits-socialpromoter' ),
							),
							'measured_at'             => array(
								'type'        => 'string',
								'description' => __( 'When the underlying data was measured (UTC, ISO 8601). Empty when unknown.', 'publisherkits-socialpromoter' ),
							),
							'age_seconds'             => array(
								'type'        => array( 'integer', 'null' ),
								'description' => __( 'Seconds since measurement. NULL means the age is unknown - it does not mean fresh.', 'publisherkits-socialpromoter' ),
							),
							'complete'                => array(
								'type'        => array( 'boolean', 'null' ),
								'description' => __( 'Whether the underlying data was complete. NULL when nothing could be measured.', 'publisherkits-socialpromoter' ),
							),
							'observed'                => array(
								'type'        => array( 'integer', 'null' ),
								'description' => __( 'Units actually covered by the measurement.', 'publisherkits-socialpromoter' ),
							),
							'expected'                => array(
								'type'        => array( 'integer', 'null' ),
								'description' => __( 'Units there should have been - the denominator. NULL when genuinely unknowable.', 'publisherkits-socialpromoter' ),
							),
							'reason'                  => array(
								'type'        => 'string',
								'description' => __( 'Why the measurement could not be taken, or was incomplete. Empty on a clean result.', 'publisherkits-socialpromoter' ),
							),
							'note'                    => array(
								'type'        => 'string',
								'description' => __( 'Any caveat a reader needs in order not to misread the figure.', 'publisherkits-socialpromoter' ),
							),
							'core_version'            => array(
								'type'        => 'string',
								'description' => __( 'The PK Core version that answered.', 'publisherkits-socialpromoter' ),
							),
						),
					),
				),
			);
		}

		/*
		 * ----------------------------------------------------------------
		 * Names, failures, introspection
		 * ----------------------------------------------------------------
		 */

		/**
		 * Qualify a bare slug with the PublisherKits namespace.
		 *
		 * @since 0.9.11
		 * @param string $name Slug or full name.
		 * @return string
		 */
		public static function qualify( $name ) {
			$name = trim( (string) $name );
			if ( '' === $name || false !== strpos( $name, '/' ) ) {
				return $name;
			}
			return self::NAMESPACE_SLUG . '/' . $name;
		}

		/**
		 * Why this name would be rejected, or '' when it is fine.
		 *
		 * Mirrors WordPress's own rule deliberately. WordPress rejects a bad
		 * name with an invisible _doing_it_wrong(); checking first turns that
		 * into a recorded failure with the actual reason in it.
		 *
		 * @since 0.9.11
		 * @param string $name Full ability name.
		 * @return string
		 */
		public static function validate_name( $name ) {
			$name = (string) $name;
			if ( '' === $name ) {
				return 'empty ability name';
			}
			if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) {
				return sprintf(
					'invalid ability name "%s" - WordPress requires namespace/slug using lowercase alphanumerics and dashes only (underscores are rejected)',
					$name
				);
			}
			return '';
		}

		/**
		 * Record a registration failure.
		 *
		 * @since 0.9.11
		 * @param string $name   Ability or category name.
		 * @param string $reason Why.
		 * @return void
		 */
		private static function record_failure( $name, $reason ) {
			self::$failures[ (string) $name ] = (string) $reason;

			/**
			 * Fires when an ability or category fails to register.
			 *
			 * WordPress reports these only through _doing_it_wrong(), which is
			 * silent in production. This action is the hook an alerting rule
			 * can actually watch.
			 *
			 * @since 0.9.11
			 * @param string $name   Ability or category name.
			 * @param string $reason Why it failed.
			 */
			do_action( 'pkcore_ability_registration_failed', (string) $name, (string) $reason );

			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only diagnostic for an otherwise invisible failure.
				error_log( sprintf( 'PK Core abilities: %s could not be registered - %s', $name, $reason ) );
			}
		}

		/**
		 * Abilities registered successfully this request: name => tier.
		 *
		 * @since 0.9.11
		 * @return array<string,string>
		 */
		public static function registered() {
			return self::$registered;
		}

		/**
		 * Registrations that FAILED this request: name => reason.
		 *
		 * Empty is the healthy state. Non-empty on a live site means abilities
		 * an agent will never find, with no other symptom.
		 *
		 * @since 0.9.11
		 * @return array<string,string>
		 */
		public static function failures() {
			return self::$failures;
		}

		/**
		 * Reset internal state. Testing only.
		 *
		 * @since 0.9.11
		 * @return void
		 */
		public static function reset_for_tests() {
			self::$registered  = array();
			self::$failures    = array();
			self::$categories  = array();
			self::$initialised = false;
		}
	}
}
