Commit 6f87b58f35e for woocommerce

commit 6f87b58f35eb6fb29cb910f565f866905d32c73f
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date:   Fri Nov 13 23:15:54 2026 +0100

    Agent readiness: list what the store declares to agents, and keep each product's check results current (#70510)

    * Agent readiness: list what the store declares to agents

    Part of #70499.

    * Agent readiness: a 200 error page is not a declared file

    A host or plugin that answers unknown paths with a 200 JSON or HTML
    error made ai.txt, AGENTS.md, auth.md, sitemap.md and security.txt all
    read as found. A file now also needs a non-HTML content type, a body
    that is not JSON, and no byte order mark in front of a page.

    * Agent readiness: read declared tags with the HTML tag processor, and keep scanning after a failed Markdown request

    The WebMCP form, Markdown alternate link, language-tagged code block,
    search autocomplete and author meta checks read real tags, so markup in a
    comment or a script no longer counts as found.

    Only a failed plain GET of the home page stops the scan's later requests;
    a Markdown request to it can fail where the page loads.

    The coming soon test asserts each item is null instead of comparing a
    loosely deduplicated list.

    * Agent readiness: Products list filter and column for failing products

    Part of #70509.

    * Agent readiness: say when the scan didn't reach a product, skip empty saves, forget trashed products

    The Agents column said Ready for any covered product without rows, but
    a product past the scan's limit has no rows either, so on a large store
    the untested products read as ready. It now says Not checked unless the
    product has its checked row.

    A save with no changes, such as a variable product's parent save after
    a checkout changes a variation's stock, tested the product again on
    every order. It is skipped like a stock-only save.

    Trashing a product leaves no product save, so its rows stayed in the
    counts until the next scan. They are removed on trash, and the product
    is tested again when it is restored.

    * Agent readiness: read the example products' meta in one query

    with_saved_products() asked for each example product's rows one product
    at a time, up to ten per check, on every read of the report: the
    settings page, the Products list, REST and the ability. The meta cache
    is now primed for all example products in one query first.

    * Agent readiness: restore the test's globals even when an assertion fails

    test_filter_query sets $pagenow, $wp_the_query, current_screen and a GET
    parameter; a failed assertion skipped the restore and left later tests on an
    admin products screen with the filter set.

    * Agent readiness: drop the Products list column and dropdown, keep the stored results and the link filter

    The column and dropdown need a design and copy pass first. Each product's
    stored results, the counts read from them, and the filter behind the
    readiness pages' links to failing products stay.

    * Agent readiness: re-test the parent when a variation goes, keep the list's meta query apart, count per site

    Trashing, restoring or deleting a variation doesn't save its parent. The
    parent is now noted on those variation hooks and tested once at the end of
    the request, so removing many variations tests it once, and a parent
    deleted in the same request has its rows removed instead of re-added.

    The readiness condition is joined to the Products list's own meta query
    with AND, so an OR relation in it can't widen the filter.

    The counts read once per request are kept per site, so a switch_to_blog()
    reads the other site's counts.

    * Agent readiness: store the GTINs products share in the report, and serve the report from one reader

    Checking one product on save or with the run ability reads the shared
    GTINs from the last scan instead of every published product. The REST
    route and the get ability return the report from get_served_report().

    * Agent readiness: cap the stored shared GTINs, and say which new duplicates wait for a scan

    Past 5,000 shared GTINs the report stores none, and a check of one product
    reads the catalog as before, so the stored report stays small.

    A GTIN shared since the last scan, on the saved product or another, is
    flagged at the next scan. Core refuses an exact duplicate on save, so this
    is one that differs only by separators or leading zeros, or a direct meta
    write. The docblock and README now say so.

    * Agent readiness: keep trunk method signatures and current product rows

    init(), evaluate() and all() stay callable as they shipped. A blocked
    declaration is unchecked, each product counts once, and a brand or MPN
    save rechecks the product.

    * Agent readiness: keep rows stored while a scan is running

    Cleanup only drops products that had rows when the scan started, so a product saved during the scan keeps the result it just stored.

    * Agent readiness: retest a parent from the database

    A variation removed in the same request is still on the cached product, so the parent kept its old result. The scan test stores its extra product after the catalog query has run.

    * Agent readiness: retest a parent from its current variations

    * Agent readiness: read variations from the database

    The per-id cache kept the first variation list, so a later GTIN, price, or image stayed missing and a trashed variation still counted.

    * Agent readiness: read published variations and mid-scan rows

diff --git a/plugins/woocommerce/changelog/add-agent-readiness-declared b/plugins/woocommerce/changelog/add-agent-readiness-declared
new file mode 100644
index 00000000000..680bf098519
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-declared
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Agent readiness: list the protocols and files a store declares to agents, as information only.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-editor-notice b/plugins/woocommerce/changelog/add-agent-readiness-editor-notice
new file mode 100644
index 00000000000..bce033c9956
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-editor-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: store shared GTINs in the report so checking one product reads no catalog; no merchant-facing change.
+
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-products-list b/plugins/woocommerce/changelog/add-agent-readiness-products-list
new file mode 100644
index 00000000000..202185504bd
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-products-list
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Agent readiness: keep each product's check results up to date, so readiness counts and links to failing products stay current.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 93d7da70728..c903c8d44f5 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -464,6 +464,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRestController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\AgentReadiness\ProductsList::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();
 		$container->get( ProductUtil::class )->register();
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index 9246678a5a5..73e270dfdfd 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -44,6 +44,13 @@ class AgentReadiness {
 	 */
 	private StoreOutputChecks $store_output_checks;

+	/**
+	 * What the store declares to agents.
+	 *
+	 * @var DeclaredToAgents
+	 */
+	private DeclaredToAgents $declared_to_agents;
+
 	/**
 	 * Rules from the published rules file.
 	 *
@@ -52,7 +59,7 @@ class AgentReadiness {
 	private RemoteRules $remote_rules;

 	/**
-	 * Scan reused for single-product checks, so its shared GTIN lookup runs once per request.
+	 * Scan reused for single-product checks, with the shared GTINs the last stored scan found.
 	 *
 	 * @var ProductScan|null
 	 */
@@ -70,13 +77,15 @@ class AgentReadiness {
 	 *
 	 * @internal
 	 *
-	 * @param StoreConditions   $store_conditions    Store-wide conditions.
-	 * @param StoreOutputChecks $store_output_checks Store checks that read what the store returns.
-	 * @param RemoteRules       $remote_rules        Rules from the published rules file.
+	 * @param StoreConditions       $store_conditions    Store-wide conditions.
+	 * @param StoreOutputChecks     $store_output_checks Store checks that read what the store returns.
+	 * @param RemoteRules           $remote_rules        Rules from the published rules file.
+	 * @param DeclaredToAgents|null $declared_to_agents  What the store declares to agents, resolved from the container when omitted.
 	 */
-	final public function init( StoreConditions $store_conditions, StoreOutputChecks $store_output_checks, RemoteRules $remote_rules ): void {
+	final public function init( StoreConditions $store_conditions, StoreOutputChecks $store_output_checks, RemoteRules $remote_rules, ?DeclaredToAgents $declared_to_agents = null ): void {
 		$this->store_conditions    = $store_conditions;
 		$this->store_output_checks = $store_output_checks;
+		$this->declared_to_agents  = $declared_to_agents ?? wc_get_container()->get( DeclaredToAgents::class );
 		$this->remote_rules        = $remote_rules;
 	}

@@ -196,9 +205,19 @@ class AgentReadiness {
 	/**
 	 * Run every check over the catalog.
 	 *
-	 * @return array{generated_at:string,products:int,hidden_from_catalog:int,unsupported_types:int,store_checks:array<int,array<string,mixed>>,channels:array<string,array<string,mixed>>,checks:array<int,array<string,mixed>>,truncated:bool}
+	 * @return array{generated_at:string,products:int,hidden_from_catalog:int,unsupported_types:int,store_checks:array<int,array<string,mixed>>,declared:array<int,array{id:string,name:string,found:bool|null}>,channels:array<string,array<string,mixed>>,checks:array<int,array<string,mixed>>,truncated:bool}
 	 */
 	public function get_report(): array {
+		return $this->build_report();
+	}
+
+	/**
+	 * Run every check over the catalog.
+	 *
+	 * @param callable|null $on_tested Called with each product id and its failures.
+	 * @return array{generated_at:string,products:int,hidden_from_catalog:int,unsupported_types:int,store_checks:array<int,array<string,mixed>>,declared:array<int,array{id:string,name:string,found:bool|null}>,channels:array<string,array<string,mixed>>,checks:array<int,array<string,mixed>>,truncated:bool,shared_gtins?:array<int,string>}
+	 */
+	private function build_report( ?callable $on_tested = null ): array {
 		// Rebuild the rules and the scan for each report: a long-running process may have fetched a new rules file or changed GTINs since.
 		$this->active_rules = null;
 		$this->product_scan = null;
@@ -207,7 +226,11 @@ class AgentReadiness {
 		$channel_rules      = $active['channel_rules'];

 		$scan = new ProductScan();
-		$scan->evaluate( $rules, $channel_rules, self::PRODUCT_IDS_LIMIT );
+		if ( null === $on_tested ) {
+			$scan->evaluate( $rules, $channel_rules, self::PRODUCT_IDS_LIMIT );
+		} else {
+			$scan->evaluate_each( $rules, $channel_rules, self::PRODUCT_IDS_LIMIT, $on_tested );
+		}

 		$checks = array();
 		foreach ( $rules as $id => $rule ) {
@@ -224,16 +247,24 @@ class AgentReadiness {
 			);
 		}

-		return array(
+		$responses = $this->store_output_checks->responses();
+
+		$report = array(
 			'generated_at'        => gmdate( 'c' ),
 			'products'            => $scan->products(),
 			'hidden_from_catalog' => $scan->hidden_from_catalog(),
 			'unsupported_types'   => $scan->unsupported_types(),
-			'store_checks'        => array_merge( $this->store_conditions->all(), $this->store_output_checks->all() ),
+			'store_checks'        => array_merge( $this->store_conditions->all(), $this->store_output_checks->all_for( $responses ) ),
+			'declared'            => $this->declared_to_agents->all( $responses ),
 			'channels'            => $scan->channel_totals(),
 			'checks'              => $checks,
 			'truncated'           => $scan->truncated(),
 		);
+		if ( null !== $on_tested && null !== $scan->shared_gtins() ) {
+			// Stored for checks of one product, so a save doesn't read every published product.
+			$report['shared_gtins'] = $scan->shared_gtins();
+		}
+		return $report;
 	}

 	/**
@@ -243,7 +274,73 @@ class AgentReadiness {
 	 */
 	public function get_cached_report(): ?array {
 		$report = get_option( self::REPORT_OPTION );
-		return is_array( $report ) ? $report : null;
+		if ( ! is_array( $report ) ) {
+			return null;
+		}
+		unset( $report['shared_gtins'] );
+		return self::with_saved_products( $report );
+	}
+
+	/**
+	 * Whether a report has been stored.
+	 */
+	public function has_report(): bool {
+		return is_array( get_option( self::REPORT_OPTION ) );
+	}
+
+	/**
+	 * A stored report with its product counts as they stand now, so a product fixed since the scan no longer counts.
+	 *
+	 * @param array<string, mixed> $report Stored report.
+	 * @return array<string, mixed>
+	 */
+	private static function with_saved_products( array $report ): array {
+		if ( empty( $report['failures_stored'] ) ) {
+			return $report;
+		}
+		$counts = ProductFailures::counts();
+		$checks = is_array( $report['checks'] ?? null ) ? $report['checks'] : array();
+		// One query for the example products' meta, instead of one per product.
+		$example_ids = array();
+		foreach ( $checks as $check ) {
+			$example_ids = array_merge( $example_ids, array_map( 'intval', is_array( $check ) ? (array) ( $check['product_ids'] ?? array() ) : array() ) );
+		}
+		if ( array() !== $example_ids ) {
+			update_meta_cache( 'post', array_unique( $example_ids ) );
+		}
+		foreach ( $checks as $index => $check ) {
+			if ( ! is_array( $check ) || ! isset( $check['id'] ) ) {
+				continue;
+			}
+			$id                                        = (string) $check['id'];
+			$report['checks'][ $index ]['count']       = $counts[ $id ] ?? 0;
+			$report['checks'][ $index ]['product_ids'] = array_values(
+				array_filter(
+					(array) ( $check['product_ids'] ?? array() ),
+					static fn( $product_id ): bool => in_array( $id, ProductFailures::for_product( (int) $product_id ), true )
+				)
+			);
+		}
+		// Every product the checks cover has a checked row, so products added, fixed or removed since the scan count now.
+		$products           = $counts[ ProductFailures::CHECKED ] ?? 0;
+		$report['products'] = $products;
+		foreach ( is_array( $report['channels'] ?? null ) ? $report['channels'] : array() as $id => $channel ) {
+			if ( ! is_array( $channel ) ) {
+				continue;
+			}
+			$not_listable              = $counts[ ProductFailures::channel_value( ProductScan::NOT_LISTABLE, (string) $id ) ] ?? 0;
+			$with_gaps                 = $counts[ ProductFailures::channel_value( ProductScan::WITH_GAPS, (string) $id ) ] ?? 0;
+			$report['channels'][ $id ] = array_merge(
+				$channel,
+				array(
+					'listable'     => max( 0, $products - $not_listable ),
+					'not_listable' => $not_listable,
+					'with_gaps'    => $with_gaps,
+					'complete'     => max( 0, $products - $not_listable - $with_gaps ),
+				)
+			);
+		}
+		return $report;
 	}

 	/**
@@ -280,14 +377,39 @@ class AgentReadiness {
 		return $checks;
 	}

+	/**
+	 * The stored report with setup checks read now, or null when there is none.
+	 * Output checks stay as the scan stored them.
+	 *
+	 * @return array<string, mixed>|null
+	 */
+	public function get_served_report(): ?array {
+		$report = $this->get_cached_report();
+		if ( null !== $report ) {
+			$report['store_checks'] = $this->get_store_checks( $report );
+		}
+		return $report;
+	}
+
 	/**
 	 * Run the report and store it. Not autoloaded: only the readiness screens read it.
 	 *
 	 * @return array<string, mixed>
 	 */
 	public function refresh_report(): array {
-		$report = $this->get_report();
+		// Captured before the scan. A product saved while it runs is absent here, so its new rows stay.
+		$had_rows = ProductFailures::stored_product_ids();
+		$tested   = array();
+		$report   = $this->build_report(
+			static function ( int $product_id, array $failures ) use ( &$tested ): void {
+				$tested[] = $product_id;
+				ProductFailures::store( $product_id, $failures );
+			}
+		);
+		ProductFailures::keep_only( $had_rows, $tested );
+		$report['failures_stored'] = true;
 		update_option( self::REPORT_OPTION, $report, false );
+		unset( $report['shared_gtins'] );
 		return $report;
 	}

@@ -312,7 +434,11 @@ class AgentReadiness {
 	 *
 	 * For extensions that rank or fix products one at a time; the stored
 	 * report keeps only counts and a few example ids per check. Shared GTINs
-	 * are read once per report, so call get_report() again after changing GTINs.
+	 * come from the last stored scan, so a GTIN shared since then, on this
+	 * product or another, is flagged at the next scan. Core refuses an exact
+	 * duplicate on save, so this is one that differs only by separators or
+	 * leading zeros, or a direct meta write. Before the first scan, or past
+	 * ProductScan::SHARED_GTINS_LIMIT, they're read from the catalog.
 	 *
 	 * @param \WC_Product $product Product or variation.
 	 * @return array{product_id:int,checks:array<string, array<string, array{field:string,effect:string}>>,statuses:array<string, string>}|null
@@ -330,6 +456,10 @@ class AgentReadiness {
 		}
 		if ( null === $this->product_scan ) {
 			$this->product_scan = new ProductScan();
+			$stored             = get_option( self::REPORT_OPTION );
+			if ( is_array( $stored ) && is_array( $stored['shared_gtins'] ?? null ) ) {
+				$this->product_scan->use_shared_gtins( $stored['shared_gtins'] );
+			}
 		}
 		$active = $this->get_active_rules();
 		$checks = $this->product_scan->failures( $product, $active['rules'], $active['channel_rules'] );
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
index 244c912acfe..407df4f205a 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
@@ -340,11 +340,11 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 				</tr>
 			</thead>
 			<tbody>
-				<?php foreach ( $channels as $channel ) : ?>
+				<?php foreach ( $channels as $channel_id => $channel ) : ?>
 					<tr>
 						<td><a href="<?php echo esc_url( (string) $channel['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( (string) $channel['name'] ); ?></a><br /><span class="description"><?php echo esc_html( (string) $channel['spec'] ); ?></span></td>
 						<td><?php echo esc_html( number_format_i18n( (int) $channel['listable'] ) ); ?></td>
-						<td><?php echo esc_html( number_format_i18n( (int) $channel['not_listable'] ) ); ?></td>
+						<td><?php echo wp_kses_post( self::count_link( (int) $channel['not_listable'], ProductFailures::channel_value( ProductScan::NOT_LISTABLE, (string) $channel_id ), ! empty( $report['failures_stored'] ) ) ); ?></td>
 						<td><?php echo esc_html( number_format_i18n( (int) $channel['with_gaps'] ) ); ?></td>
 						<td><?php echo esc_html( number_format_i18n( (int) $channel['complete'] ) ); ?></td>
 					</tr>
@@ -375,7 +375,7 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 							<p><a href="<?php echo esc_url( (string) $check['fix_url'] ); ?>"><?php echo esc_html( (string) $check['fix'] ); ?></a></p>
 						</td>
 						<td><?php echo empty( $check['channels'] ) ? esc_html__( 'Product page only', 'woocommerce' ) : wp_kses_post( implode( '<br />', $this->channel_tags( (array) $check['channels'], $channels ) ) ); ?></td>
-						<td><?php echo esc_html( number_format_i18n( (int) $check['count'] ) ); ?></td>
+						<td><?php echo wp_kses_post( self::count_link( (int) $check['count'], (string) ( $check['id'] ?? '' ), ! empty( $report['failures_stored'] ) ) ); ?></td>
 						<td><?php echo wp_kses_post( implode( '<br />', $this->product_links( (array) ( $check['product_ids'] ?? array() ) ) ) ); ?></td>
 					</tr>
 				<?php endforeach; ?>
@@ -385,6 +385,19 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 		<?php
 	}

+	/**
+	 * A count, linked to the Products list filtered to those products when each product's failures are stored.
+	 *
+	 * @param int    $count  Number of products.
+	 * @param string $value  Stored failure value.
+	 * @param bool   $linked Whether the list can be filtered.
+	 * @return string Escaped HTML.
+	 */
+	private static function count_link( int $count, string $value, bool $linked ): string {
+		$text = esc_html( number_format_i18n( $count ) );
+		return $linked && $count > 0 ? '<a href="' . esc_url( ProductsList::url( $value ) ) . '">' . $text . '</a>' : $text;
+	}
+
 	/**
 	 * "Required by X" or "Harder to find on X" per channel.
 	 *
@@ -553,6 +566,37 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 		<?php
 	}

+	/**
+	 * Protocols and files the store declares to agents, as information: found or not found, never pass or fail.
+	 *
+	 * @param array $items Items from the report.
+	 */
+	private function render_declared( array $items ): void {
+		$items = array_filter( $items, 'is_array' );
+		if ( array() === $items ) {
+			return;
+		}
+		$found   = count( array_filter( $items, static fn( array $item ): bool => true === ( $item['found'] ?? null ) ) );
+		$unknown = count( array_filter( $items, static fn( array $item ): bool => null === ( $item['found'] ?? null ) ) );
+		if ( count( $items ) === $unknown ) {
+			$summary = __( 'Not checked', 'woocommerce' );
+		} elseif ( $unknown > 0 ) {
+			/* translators: 1: number found, 2: number looked for, 3: number that could not be checked. */
+			$summary = sprintf( __( '%1$d of %2$d found, %3$d not checked', 'woocommerce' ), $found, count( $items ), $unknown );
+		} else {
+			/* translators: 1: number found, 2: number looked for. */
+			$summary = sprintf( __( '%1$d of %2$d found', 'woocommerce' ), $found, count( $items ) );
+		}
+		echo '<div class="wc-agentic-commerce-card__section wc-agentic-commerce-declared-section"><h3>' . esc_html__( 'What this store declares to agents', 'woocommerce' ) . '</h3>';
+		echo '<p>' . esc_html__( 'For information. Most stores don\'t need these, and none of them counts as a problem.', 'woocommerce' ) . '</p>';
+		echo '<details><summary>' . esc_html( $summary ) . '</summary><table class="widefat striped wc-agentic-commerce-declared"><tbody>';
+		foreach ( $items as $item ) {
+			$state = null !== ( $item['found'] ?? null ) ? ( $item['found'] ? __( 'Found', 'woocommerce' ) : __( 'Not found', 'woocommerce' ) ) : __( 'Not checked', 'woocommerce' );
+			echo '<tr><td>' . esc_html( (string) ( $item['name'] ?? '' ) ) . '</td><td>' . esc_html( $state ) . '</td></tr>';
+		}
+		echo '</tbody></table></details></div>';
+	}
+
 	/**
 	 * One line on where a question stands.
 	 *
@@ -758,6 +802,7 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
 					}
 					?>
 				<?php endforeach; ?>
+				<?php $this->render_declared( is_array( $report['declared'] ?? null ) ? $report['declared'] : array() ); ?>
 			<?php endif; ?>
 		</div>
 		<?php
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/DeclaredToAgents.php b/plugins/woocommerce/src/Internal/AgentReadiness/DeclaredToAgents.php
new file mode 100644
index 00000000000..c7f6748c508
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/DeclaredToAgents.php
@@ -0,0 +1,384 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * What the store declares to agents: protocols and files that are found or not found,
+ * never pass or fail, and never count as a problem. Runs only in the background scan.
+ *
+ * `found` is null when the store could not be read.
+ *
+ * @since 11.4.0
+ */
+class DeclaredToAgents {
+
+	/**
+	 * Every item with whether the store serves it.
+	 *
+	 * @param StoreResponses $responses Responses of the scan.
+	 * @return array<int, array{id:string,name:string,found:bool|null}>
+	 */
+	public function all( StoreResponses $responses ): array {
+		$responses->home_page();
+		$items = array();
+		foreach ( $this->files() as $id => $file ) {
+			$items[] = array(
+				'id'    => $id,
+				'name'  => $file[0],
+				'found' => self::file_served( $responses, $file[1] ),
+			);
+		}
+		foreach ( $this->documents() as $id => $document ) {
+			$items[] = array(
+				'id'    => $id,
+				'name'  => $document[0],
+				'found' => self::document_served( $responses, $document[1], $document[2] ),
+			);
+		}
+		$robots = $responses->get( StoreOutputChecks::host_root( '/robots.txt' ) );
+		$home   = $responses->home_page();
+		$page   = $responses->product_page();
+		$feed   = $responses->get( '/feed/' );
+		$lines  = array(
+			'feed-present'             => array( __( 'RSS or Atom feed', 'woocommerce' ), null === $feed || StoreOutputChecks::blocked( $feed['status'] ) ? null : 200 === $feed['status'] && 1 === preg_match( '/<(rss|feed)[\s>]/i', $feed['body'] ) ),
+			'sameas-links'             => array( __( 'sameAs links in structured data', 'woocommerce' ), self::json_ld_has( $home, 'sameAs' ) ),
+			'site-search-action'       => array( __( 'SearchAction for on-site search', 'woocommerce' ), self::has_search_action( $home ) ),
+			'webmcp-forms-schema'      => array( __( 'Forms that expose declarative WebMCP', 'woocommerce' ), self::page_has_tag( array( $home, $page ), array( 'FORM' ), static fn( \WP_HTML_Tag_Processor $tag ): bool => null !== $tag->get_attribute( 'toolname' ) && null !== $tag->get_attribute( 'tooldescription' ) ) ),
+			'sitemap-lastmod'          => array( __( 'Sitemap has lastmod dates', 'woocommerce' ), self::sitemap_has_lastmod( $responses, $robots ) ),
+			'markdown-pages'           => array( __( 'Markdown version of pages (Accept: text/markdown)', 'woocommerce' ), self::is_markdown( self::markdown_answer( $responses ) ) ),
+			'markdown-alternate-link'  => array( __( 'Pages link to their Markdown version', 'woocommerce' ), self::page_has_tag( array( $home, $page ), array( 'LINK' ), static fn( \WP_HTML_Tag_Processor $tag ): bool => in_array( 'alternate', self::tokens( $tag->get_attribute( 'rel' ) ), true ) && 0 === stripos( trim( (string) $tag->get_attribute( 'type' ) ), 'text/markdown' ) ) ),
+			'markdown-frontmatter'     => array( __( 'Markdown versions start with frontmatter', 'woocommerce' ), self::has_frontmatter( self::markdown_answer( $responses ) ) ),
+			'product-review-schema'    => array( __( 'Product review markup', 'woocommerce' ), null === $page ? null : self::product_has_reviews( $page['body'] ) ),
+			'code-block-language'      => array( __( 'Code blocks tagged with their language', 'woocommerce' ), self::page_has_tag( array( $home, $page ), array( 'PRE', 'CODE' ), static fn( \WP_HTML_Tag_Processor $tag ): bool => 1 === preg_match( '/(?:^|\s)(?:language|lang)-[\w+#-]+/i', (string) $tag->get_attribute( 'class' ) ) ) ),
+			'editorial-metadata'       => array( __( 'Author and publish date', 'woocommerce' ), self::has_editorial_metadata( array( $home, $page ) ) ),
+			'site-search-autocomplete' => array( __( 'On-site search with autocomplete', 'woocommerce' ), self::page_has_tag( array( $home ), array( 'INPUT' ), array( self::class, 'is_autocomplete_search' ) ) ),
+			'mcp-server-card'          => array( __( 'MCP server card', 'woocommerce' ), self::either( self::document_served( $responses, '/.well-known/mcp/server-card.json', array( self::class, 'is_mcp_server_card' ) ), self::document_served( $responses, '/.well-known/mcp.json', array( self::class, 'is_mcp_server_card' ) ) ) ),
+			// Not read yet: DNS-AID needs a DNS lookup, and a feed product sync is set up outside the store's pages.
+			'dns-aid'                  => array( __( 'DNS for AI Discovery (DNS-AID)', 'woocommerce' ), null ),
+			'product-feed-automation'  => array( __( 'Automated product feed', 'woocommerce' ), null ),
+		);
+		foreach ( $lines as $id => $line ) {
+			$items[] = array(
+				'id'    => $id,
+				'name'  => $line[0],
+				'found' => $line[1],
+			);
+		}
+		return $items;
+	}
+
+	/**
+	 * Items served as a file at a known path, by id.
+	 *
+	 * @return array<string, array{0:string,1:string}> Name and path from the host root.
+	 */
+	private function files(): array {
+		return array(
+			'ai-txt'       => array( __( 'ai.txt usage file', 'woocommerce' ), '/ai.txt' ),
+			'sitemap-md'   => array( __( 'Markdown sitemap', 'woocommerce' ), '/sitemap.md' ),
+			'agents-md'    => array( __( 'AGENTS.md', 'woocommerce' ), '/AGENTS.md' ),
+			'security-txt' => array( __( 'security.txt', 'woocommerce' ), '/.well-known/security.txt' ),
+			'auth-md'      => array( __( 'auth.md agent sign-in instructions', 'woocommerce' ), '/auth.md' ),
+		);
+	}
+
+	/**
+	 * Items served as a JSON document at a known path, by id. A 200 alone doesn't count: some
+	 * stores answer any path with a JSON error, so each document has to have what its spec requires.
+	 *
+	 * @return array<string, array{0:string,1:string,2:callable(array<mixed>):bool}> Name, path from the host root, and test.
+	 */
+	private function documents(): array {
+		$has = static fn( string ...$keys ): callable => static fn( array $document ): bool => array() === array_diff( $keys, array_keys( array_filter( $document ) ) );
+		return array(
+			'tdmrep'                   => array( __( 'TDMRep text-and-data-mining reservation', 'woocommerce' ), '/.well-known/tdmrep.json', static fn( array $document ): bool => isset( $document[0]['location'] ) ),
+			'a2a-agent-card'           => array( __( 'A2A Agent Card', 'woocommerce' ), '/.well-known/agent-card.json', $has( 'name', 'skills' ) ),
+			'agent-skills-index'       => array( __( 'Agent Skills index', 'woocommerce' ), '/.well-known/agent-skills/index.json', $has( 'skills' ) ),
+			'openapi-spec'             => array( __( 'OpenAPI description', 'woocommerce' ), '/openapi.json', $has( 'openapi' ) ),
+			'agents-json'              => array( __( 'agents.json', 'woocommerce' ), '/.well-known/agents.json', $has( 'agentsJson', 'flows' ) ),
+			'web-bot-auth'             => array( __( 'Web Bot Auth key directory', 'woocommerce' ), '/.well-known/http-message-signatures-directory', $has( 'keys' ) ),
+			'oauth-discovery'          => array( __( 'OAuth authorization server metadata', 'woocommerce' ), '/.well-known/oauth-authorization-server', $has( 'issuer' ) ),
+			'oauth-protected-resource' => array( __( 'OAuth Protected Resource metadata', 'woocommerce' ), '/.well-known/oauth-protected-resource', $has( 'resource' ) ),
+			'poppy-json'               => array( __( 'Personal Agent Protocol (poppy.json)', 'woocommerce' ), '/.well-known/poppy.json', array( self::class, 'is_poppy_document' ) ),
+			'acp-support'              => array( __( 'Agentic Commerce Protocol (ACP) declared', 'woocommerce' ), '/.well-known/acp.json', static fn( array $document ): bool => 'acp' === ( $document['protocol']['name'] ?? '' ) ),
+			'ard-manifest'             => array( __( 'Agentic Resource Discovery manifest', 'woocommerce' ), '/.well-known/ard.json', static fn( array $document ): bool => is_array( $document['entries'] ?? null ) ),
+			'ai-catalog'               => array( __( 'AI catalog discovery', 'woocommerce' ), '/.well-known/ai-catalog.json', static fn( array $document ): bool => isset( $document['specVersion'] ) && is_array( $document['entries'] ?? null ) ),
+			'mpp'                      => array( __( 'Machine Payments Protocol (MPP)', 'woocommerce' ), '/openapi.json', array( self::class, 'has_payment_info' ) ),
+		);
+	}
+
+	/**
+	 * Whether a document is a Personal Agent Protocol one for this store: it names its version and this host.
+	 *
+	 * @param array<mixed> $document Decoded document.
+	 */
+	private static function is_poppy_document( array $document ): bool {
+		$domain = $document['organization']['domain'] ?? null;
+		return ! empty( $document['protocol_version'] ) && is_string( $domain ) && wp_parse_url( home_url(), PHP_URL_HOST ) === $domain;
+	}
+
+	/**
+	 * Whether an OpenAPI document has an operation with Machine Payments Protocol payment information.
+	 *
+	 * @param array<mixed> $document Decoded document.
+	 */
+	private static function has_payment_info( array $document ): bool {
+		foreach ( (array) ( $document['paths'] ?? array() ) as $operations ) {
+			foreach ( (array) $operations as $operation ) {
+				if ( is_array( $operation ) && isset( $operation['x-payment-info'] ) ) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Whether any of the pages that loaded has a tag that matches. Tags are read as the browser
+	 * reads them, so markup inside a comment or a script doesn't count. Null when none loaded.
+	 *
+	 * @param array<int, array{status:int,body:string,headers:array<string,string>}|null> $pages     Pages, null for one that didn't load.
+	 * @param array<int, string>                                                          $tag_names Upper-case tag names.
+	 * @param callable(\WP_HTML_Tag_Processor): bool                                      $matches   Test for one tag.
+	 */
+	private static function page_has_tag( array $pages, array $tag_names, callable $matches ): ?bool {
+		$loaded = array_filter( $pages );
+		if ( array() === $loaded ) {
+			return null;
+		}
+		foreach ( $loaded as $page ) {
+			$tags = new \WP_HTML_Tag_Processor( $page['body'] );
+			while ( $tags->next_tag() ) {
+				if ( in_array( $tags->get_tag(), $tag_names, true ) && ! $tags->is_tag_closer() && $matches( $tags ) ) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * The space-separated tokens of an attribute value, lower case.
+	 *
+	 * @param mixed $value Attribute value.
+	 * @return array<int, string>
+	 */
+	private static function tokens( $value ): array {
+		return is_string( $value ) ? array_values( array_filter( explode( ' ', strtolower( (string) preg_replace( '/\s+/', ' ', $value ) ) ) ) ) : array();
+	}
+
+	/**
+	 * Whether an input is a site search box with autocomplete.
+	 *
+	 * @param \WP_HTML_Tag_Processor $tag Input tag.
+	 */
+	private static function is_autocomplete_search( \WP_HTML_Tag_Processor $tag ): bool {
+		$search = 'search' === strtolower( trim( (string) $tag->get_attribute( 'type' ) ) ) || 's' === $tag->get_attribute( 'name' );
+		return $search && ( ! in_array( strtolower( trim( (string) $tag->get_attribute( 'aria-autocomplete' ) ) ), array( '', 'none' ), true ) || 'combobox' === strtolower( trim( (string) $tag->get_attribute( 'role' ) ) ) || null !== $tag->get_attribute( 'list' ) );
+	}
+
+	/**
+	 * Whether a page names an author or a publish date, in meta tags or in JSON-LD. Null when none loaded.
+	 *
+	 * @param array<int, array{status:int,body:string,headers:array<string,string>}|null> $pages Pages, null for one that didn't load.
+	 */
+	private static function has_editorial_metadata( array $pages ): ?bool {
+		$meta = self::page_has_tag(
+			$pages,
+			array( 'META' ),
+			static fn( \WP_HTML_Tag_Processor $tag ): bool => in_array( strtolower( trim( (string) $tag->get_attribute( 'property' ) ) ), array( 'article:published_time', 'article:author' ), true ) || 'author' === strtolower( trim( (string) $tag->get_attribute( 'name' ) ) )
+		);
+		if ( true !== $meta ) {
+			foreach ( array_filter( $pages ) as $page ) {
+				foreach ( JsonLd::items( $page['body'], 'Article', 'BlogPosting', 'NewsArticle', 'WebPage', 'ItemPage', 'CollectionPage' ) as $item ) {
+					if ( ! empty( $item['datePublished'] ) || ! empty( $item['author'] ) ) {
+						return true;
+					}
+				}
+			}
+		}
+		return $meta;
+	}
+
+	/**
+	 * True when either result is true, null when neither is but one couldn't be checked.
+	 *
+	 * @param bool|null $first  First result.
+	 * @param bool|null $second Second result.
+	 */
+	private static function either( ?bool $first, ?bool $second ): ?bool {
+		if ( true === $first || true === $second ) {
+			return true;
+		}
+		return null === $first || null === $second ? null : false;
+	}
+
+	/**
+	 * Whether a document is an MCP server card: it names the server and says where to connect.
+	 *
+	 * @param array<mixed> $document Decoded document.
+	 */
+	private static function is_mcp_server_card( array $document ): bool {
+		$named = ! empty( $document['name'] ) || ! empty( $document['serverInfo']['name'] );
+		return $named && ( ! empty( $document['endpoint'] ) || ! empty( $document['url'] ) || ! empty( $document['remotes'] ) || ! empty( $document['transport'] ) );
+	}
+
+	/**
+	 * Whether the store's first sitemap has lastmod dates, in a sitemap index or in the first sitemap it lists.
+	 *
+	 * @param StoreResponses                                                  $responses Store responses.
+	 * @param array{status:int,body:string,headers:array<string,string>}|null $robots    The robots.txt response.
+	 */
+	private static function sitemap_has_lastmod( StoreResponses $responses, ?array $robots ): ?bool {
+		// Without robots.txt the store's own sitemap list isn't known.
+		$unknown = null === $robots || ( $robots['status'] >= 400 && 404 !== $robots['status'] );
+		foreach ( StoreOutputChecks::sitemap_urls( 200 === ( $robots['status'] ?? 0 ) ? $robots['body'] : '' ) as $url ) {
+			$response = $responses->get( $url );
+			if ( null === $response || StoreOutputChecks::blocked( $response['status'] ) ) {
+				$unknown = true;
+				continue;
+			}
+			if ( 200 !== $response['status'] || 1 !== preg_match( '/<(urlset|sitemapindex)[\s>]/i', $response['body'] ) ) {
+				continue;
+			}
+			if ( false !== stripos( $response['body'], '<lastmod' ) ) {
+				return true;
+			}
+			if ( 1 !== preg_match( '#<sitemapindex.*?<loc>\s*(.+?)\s*</loc>#is', $response['body'], $first ) ) {
+				return false;
+			}
+			$child = $responses->get( html_entity_decode( $first[1], ENT_QUOTES | ENT_XML1 ) );
+			return null === $child || StoreOutputChecks::blocked( $child['status'] ) ? null : 200 === $child['status'] && false !== stripos( $child['body'], '<lastmod' );
+		}
+		return $unknown ? null : false;
+	}
+
+	/**
+	 * The product page, or the home page without a product, asked for as Markdown.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @return array{status:int,body:string,headers:array<string,string>}|null
+	 */
+	private static function markdown_answer( StoreResponses $responses ): ?array {
+		$product = $responses->product();
+		return $responses->get_as_markdown( null === $product ? '/' : (string) $product->get_permalink() );
+	}
+
+	/**
+	 * Whether a response is Markdown. A page a theme answers with for any address doesn't count.
+	 *
+	 * @param array{status:int,body:string,headers:array<string,string>}|null $response Response, null when it couldn't be loaded.
+	 */
+	private static function is_markdown( ?array $response ): ?bool {
+		if ( null === $response || StoreOutputChecks::blocked( $response['status'] ) ) {
+			return null;
+		}
+		$type = $response['headers']['content-type'] ?? '';
+		return 200 === $response['status'] && '' !== trim( $response['body'] ) && ! str_starts_with( ltrim( $response['body'] ), '<' )
+			&& ( 0 === stripos( $type, 'text/markdown' ) || 0 === stripos( $type, 'text/plain' ) );
+	}
+
+	/**
+	 * Whether the Markdown version of the page starts with a frontmatter block. Null when it couldn't be loaded.
+	 *
+	 * @param array{status:int,body:string,headers:array<string,string>}|null $response Markdown answer.
+	 */
+	private static function has_frontmatter( ?array $response ): ?bool {
+		$markdown = self::is_markdown( $response );
+		if ( true !== $markdown || null === $response ) {
+			return $markdown;
+		}
+		return 1 === preg_match( '/\A\s*---\r?\n.*?\r?\n---\r?\n/s', $response['body'] );
+	}
+
+	/**
+	 * Whether a path answers with a JSON document that passes a test.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param string         $path      Path from the host root.
+	 * @param callable       $test      Test on the decoded document.
+	 */
+	private static function document_served( StoreResponses $responses, string $path, callable $test ): ?bool {
+		$response = $responses->get( StoreOutputChecks::host_root( $path ) );
+		if ( null === $response || StoreOutputChecks::blocked( $response['status'] ) ) {
+			return null;
+		}
+		$document = 200 === $response['status'] ? json_decode( $response['body'], true ) : null;
+		return is_array( $document ) && $test( $document );
+	}
+
+	/**
+	 * Whether a path answers with a text file. A theme that answers every URL with a page, or a host
+	 * that answers unknown paths with a 200 JSON or HTML error, has no such file.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param string         $path      Path from the host root.
+	 */
+	private static function file_served( StoreResponses $responses, string $path ): ?bool {
+		$response = $responses->get( StoreOutputChecks::host_root( $path ) );
+		if ( null === $response || StoreOutputChecks::blocked( $response['status'] ) ) {
+			return null;
+		}
+		$body = ltrim( $response['body'], " \t\r\n\xEF\xBB\xBF" );
+		return 200 === $response['status'] && '' !== $body && '<' !== $body[0]
+			&& 0 !== stripos( $response['headers']['content-type'] ?? '', 'text/html' )
+			&& null === json_decode( $body );
+	}
+
+	/**
+	 * Whether a page's JSON-LD has a property on an item that describes the store.
+	 *
+	 * @param array{status:int,body:string,headers:array<string,string>}|null $page     Page, null when it didn't load.
+	 * @param string                                                          $property Property name.
+	 */
+	private static function json_ld_has( ?array $page, string $property ): ?bool {
+		if ( null === $page ) {
+			return null;
+		}
+		$types = array_merge( StoreOutputChecks::ORGANIZATION_TYPES, array( 'WebSite', 'Person' ) );
+		foreach ( JsonLd::items( $page['body'], ...$types ) as $item ) {
+			if ( ! empty( $item[ $property ] ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Whether a page's WebSite JSON-LD offers a SearchAction, on its own or in a list of actions.
+	 *
+	 * @param array{status:int,body:string,headers:array<string,string>}|null $page Page, null when it didn't load.
+	 */
+	private static function has_search_action( ?array $page ): ?bool {
+		if ( null === $page ) {
+			return null;
+		}
+		foreach ( JsonLd::items( $page['body'], 'WebSite' ) as $item ) {
+			$actions = $item['potentialAction'] ?? array();
+			$actions = is_array( $actions ) && isset( $actions['@type'] ) ? array( $actions ) : (array) $actions;
+			foreach ( $actions as $action ) {
+				if ( is_array( $action ) && in_array( 'SearchAction', preg_replace( '#^.*[/:]#', '', (array) ( $action['@type'] ?? array() ) ), true ) ) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Whether the product page's JSON-LD has reviews or a rating.
+	 *
+	 * @param string $html Product page HTML.
+	 */
+	private static function product_has_reviews( string $html ): bool {
+		foreach ( JsonLd::items( $html, 'Product', 'ProductGroup' ) as $item ) {
+			if ( ! empty( $item['review'] ) || ! empty( $item['aggregateRating'] ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFailures.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFailures.php
new file mode 100644
index 00000000000..65fe5f04273
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFailures.php
@@ -0,0 +1,140 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * What each product fails, kept in product meta so the Products list can filter on it.
+ * One row per value: a check id, or a status with a channel such as not_listable:chatgpt,
+ * plus `checked` on every product the checks cover, so the product total stays live too.
+ * A product with no rows is one the checks leave out.
+ *
+ * @since 11.4.0
+ */
+class ProductFailures {
+
+	public const META_KEY = '_wc_agent_readiness_failed';
+
+	/**
+	 * The value every checked product has, passing or not.
+	 */
+	public const CHECKED = 'checked';
+
+	/**
+	 * Counts read in this request, by site id, so a switch_to_blog() reads the other site's; cleared on every write.
+	 *
+	 * @var array<int, array<string, int>>
+	 */
+	private static array $counts = array();
+
+	/**
+	 * The value for a status on a channel.
+	 *
+	 * @param string $status  ProductScan status.
+	 * @param string $channel Channel id.
+	 */
+	public static function channel_value( string $status, string $channel ): string {
+		return $status . ':' . $channel;
+	}
+
+	/**
+	 * Store what a product fails, writing only what changed.
+	 *
+	 * @param int                                                             $product_id Product id.
+	 * @param array<string, array<string, array{field:string,effect:string}>> $failures   Channel map of each failed check, by check id.
+	 */
+	public static function store( int $product_id, array $failures ): void {
+		$values = array_merge( array( self::CHECKED ), array_map( 'strval', array_keys( $failures ) ) );
+		foreach ( ProductScan::statuses( array_values( $failures ) ) as $channel => $status ) {
+			if ( ProductScan::COMPLETE !== $status ) {
+				$values[] = self::channel_value( $status, $channel );
+			}
+		}
+		unset( self::$counts[ get_current_blog_id() ] );
+		$stored = self::stored( $product_id );
+		foreach ( array_diff( $stored, $values ) as $value ) {
+			delete_post_meta( $product_id, self::META_KEY, $value );
+		}
+		foreach ( array_diff( $values, $stored ) as $value ) {
+			add_post_meta( $product_id, self::META_KEY, $value );
+		}
+	}
+
+	/**
+	 * What a product fails.
+	 *
+	 * @param int $product_id Product id.
+	 * @return array<int, string>
+	 */
+	public static function for_product( int $product_id ): array {
+		return array_values( array_diff( self::stored( $product_id ), array( self::CHECKED ) ) );
+	}
+
+	/**
+	 * Remove a product's rows, for a product the checks no longer cover.
+	 *
+	 * @param int $product_id Product id.
+	 */
+	public static function forget( int $product_id ): void {
+		unset( self::$counts[ get_current_blog_id() ] );
+		delete_post_meta( $product_id, self::META_KEY );
+	}
+
+	/**
+	 * Every stored value of a product, `checked` included. Empty for a product no scan or save has tested.
+	 *
+	 * @param int $product_id Product id.
+	 * @return array<int, string>
+	 */
+	public static function stored( int $product_id ): array {
+		return array_map( 'strval', (array) get_post_meta( $product_id, self::META_KEY, false ) );
+	}
+
+	/**
+	 * Product ids that have stored rows right now.
+	 *
+	 * @return array<int, int>
+	 */
+	public static function stored_product_ids(): array {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Only this query finds rows of products the scan didn't visit.
+		return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_KEY ) ) );
+	}
+
+	/**
+	 * Remove the rows of products a scan didn't test, such as those now hidden from the catalog.
+	 * $had_rows is taken before the scan, so a product that stores rows during the scan keeps them.
+	 *
+	 * @param array<int, int> $had_rows Product ids that had rows when the scan started.
+	 * @param array<int, int> $tested   Product ids the scan tested.
+	 */
+	public static function keep_only( array $had_rows, array $tested ): void {
+		unset( self::$counts[ get_current_blog_id() ] );
+		foreach ( array_diff( $had_rows, $tested ) as $product_id ) {
+			self::forget( $product_id );
+		}
+	}
+
+	/**
+	 * How many products have each value. Read once per request per site: the report and the settings page both ask.
+	 *
+	 * @return array<string, int>
+	 */
+	public static function counts(): array {
+		global $wpdb;
+		$site = get_current_blog_id();
+		if ( isset( self::$counts[ $site ] ) ) {
+			return self::$counts[ $site ];
+		}
+		// Two saves can both add a value before either sees it, so count products, not rows.
+		$rows   = (array) $wpdb->get_results( $wpdb->prepare( "SELECT meta_value, COUNT(DISTINCT post_id) AS products FROM {$wpdb->postmeta} WHERE meta_key = %s GROUP BY meta_value", self::META_KEY ), ARRAY_A );
+		$counts = array();
+		foreach ( $rows as $row ) {
+			$counts[ (string) $row['meta_value'] ] = (int) $row['products'];
+		}
+		self::$counts[ $site ] = $counts;
+		return $counts;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
index 2e9ba32c056..c066bd84516 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
@@ -113,37 +113,79 @@ class ProductFields {
 		return ! in_array( $product->get_type(), array( 'bundle', 'composite', 'grouped' ), true );
 	}

+	/**
+	 * Product instance whose variations were last read.
+	 *
+	 * @var \WC_Product|null
+	 */
+	private static ?\WC_Product $cached_variation_product = null;
+
+	/**
+	 * Published variations of the cached product instance.
+	 *
+	 * @var array<int, \WC_Product_Variation>
+	 */
+	private static array $cached_variations = array();
+
 	/**
 	 * Published variations of a variable product; empty for other types. Kept
-	 * for the last product asked about, since every rule asks about the same
-	 * product in turn.
+	 * for the last product instance asked about, since every rule asks about the
+	 * same instance in turn. Another instance of the same product is read again.
 	 *
 	 * @param \WC_Product $product Product.
 	 * @return array<int, \WC_Product_Variation>
 	 */
 	public static function variations( \WC_Product $product ): array {
-		static $last_product    = null;
-		static $last_variations = array();
-
 		if ( ! $product instanceof \WC_Product_Variable ) {
 			return array();
 		}
-		if ( $product === $last_product ) {
-			return $last_variations;
+		if ( self::$cached_variation_product === $product ) {
+			return self::$cached_variations;
 		}

 		$variations = array();
-		foreach ( $product->get_children() as $child_id ) {
-			$variation = wc_get_product( (int) $child_id );
-			if ( $variation instanceof \WC_Product_Variation && 'publish' === $variation->get_status() ) {
+		foreach ( self::published_variation_ids( $product ) as $child_id ) {
+			$variation = new \WC_Product_Variation( $child_id );
+			if ( $variation->get_id() > 0 && 'publish' === $variation->get_status() ) {
 				$variations[] = $variation;
 			}
 		}
-		$last_product    = $product;
-		$last_variations = $variations;
+		self::$cached_variation_product = $product;
+		self::$cached_variations        = $variations;
 		return $variations;
 	}

+	/**
+	 * Drop the remembered variations of a product, so the next read sees a variation removed in this request.
+	 *
+	 * @param int $product_id Product id.
+	 */
+	public static function forget_variations( int $product_id ): void {
+		if ( self::$cached_variation_product instanceof \WC_Product && self::$cached_variation_product->get_id() === $product_id ) {
+			self::$cached_variation_product = null;
+			self::$cached_variations        = array();
+		}
+	}
+
+	/**
+	 * Published variation ids, from the posts table. The children transient and the
+	 * posts query cache can still list a variation after this request trashed it.
+	 *
+	 * @param \WC_Product $product Variable product.
+	 * @return array<int, int>
+	 */
+	private static function published_variation_ids( \WC_Product $product ): array {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The cached children query still returns a variation this request trashed.
+		$ids = $wpdb->get_col(
+			$wpdb->prepare(
+				"SELECT ID FROM {$wpdb->posts} WHERE post_parent = %d AND post_type = 'product_variation' AND post_status = 'publish'",
+				$product->get_id()
+			)
+		);
+		return array_map( 'intval', (array) $ids );
+	}
+
 	/**
 	 * Normalised GTINs on the product and its published variations.
 	 *
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
index f1ef4960b20..c5189ec5ccb 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
@@ -64,6 +64,11 @@ class ProductScan {
 	 */
 	private array $statuses = array();

+	/**
+	 * Most shared GTINs the stored report keeps.
+	 */
+	public const SHARED_GTINS_LIMIT = 5000;
+
 	/**
 	 * Normalised GTIN keys carried by more than one published product.
 	 *
@@ -78,6 +83,13 @@ class ProductScan {
 	 */
 	private bool $truncated = false;

+	/**
+	 * Called with each product id and its failures during evaluate_each(). Cleared when that pass ends.
+	 *
+	 * @var callable|null
+	 */
+	private $tested_callback = null;
+
 	/**
 	 * Whether the readiness checks test this product.
 	 *
@@ -101,6 +113,25 @@ class ProductScan {
 		return is_string( $type ) && '' !== $type ? $type : 'simple';
 	}

+	/**
+	 * Test every covered product, calling $on_tested with each product id and its failures.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param array<string, ReadinessRule>                                    $rules         Rules by id.
+	 * @param array<string, array<string, array{field:string,effect:string}>> $channel_rules Channel map by rule id.
+	 * @param int                                                             $ids_limit     Product ids kept per rule.
+	 * @param callable                                                        $on_tested     Called with each product id and its failures.
+	 */
+	public function evaluate_each( array $rules, array $channel_rules, int $ids_limit, callable $on_tested ): void {
+		$this->tested_callback = $on_tested;
+		try {
+			$this->evaluate( $rules, $channel_rules, $ids_limit );
+		} finally {
+			$this->tested_callback = null;
+		}
+	}
+
 	/**
 	 * Test every covered product against the rules.
 	 *
@@ -164,6 +195,17 @@ class ProductScan {
 				$query->posts
 			);
 			$batch_count = count( $product_ids );
+			/**
+			 * Fires after one batch of product ids has been read, before those products are tested.
+			 * A product created here is not in this batch, so the scan does not replace its rows.
+			 *
+			 * An ids query returns before `posts_results` and `the_posts`, so those filters never see this query.
+			 *
+			 * @since 11.4.0
+			 *
+			 * @param int[] $product_ids Product ids in this batch.
+			 */
+			do_action( 'woocommerce_agent_readiness_scan_batch', $product_ids );
 			self::prime_related( $product_ids );
 			foreach ( $product_ids as $product_id ) {
 				if ( self::PRODUCT_LIMIT === $this->products ) {
@@ -182,6 +224,7 @@ class ProductScan {
 			}
 			++$page;
 		} while ( self::BATCH_SIZE === $batch_count );
+		$this->tested_callback = null;
 	}

 	/**
@@ -385,6 +428,35 @@ class ProductScan {
 	 * @param string $gtin Normalised GTIN.
 	 */
 	public function is_shared_gtin( string $gtin ): bool {
+		return isset( $this->shared_gtin_keys()[ ProductFields::gtin_key( $gtin ) ] );
+	}
+
+	/**
+	 * The GTIN keys more than one published product carries, for the scan to store.
+	 * Null past SHARED_GTINS_LIMIT, so the stored report stays small; checking one product then reads the catalog.
+	 *
+	 * @return array<int, string>|null
+	 */
+	public function shared_gtins(): ?array {
+		$keys = $this->shared_gtin_keys();
+		return count( $keys ) > self::SHARED_GTINS_LIMIT ? null : array_map( 'strval', array_keys( $keys ) );
+	}
+
+	/**
+	 * Use the shared GTIN keys a stored scan found, instead of reading every published product.
+	 *
+	 * @param array<int, mixed> $keys GTIN keys.
+	 */
+	public function use_shared_gtins( array $keys ): void {
+		$this->shared_gtins = array_fill_keys( array_map( 'strval', array_filter( $keys, 'is_scalar' ) ), true );
+	}
+
+	/**
+	 * Shared GTIN keys, read from the catalog the first time they're needed.
+	 *
+	 * @return array<string, bool>
+	 */
+	private function shared_gtin_keys(): array {
 		if ( null === $this->shared_gtins ) {
 			global $wpdb;

@@ -430,8 +502,7 @@ class ProductScan {

 			$this->shared_gtins = array_fill_keys( array_map( 'strval', $gtins ), true );
 		}
-
-		return isset( $this->shared_gtins[ ProductFields::gtin_key( $gtin ) ] );
+		return $this->shared_gtins;
 	}

 	/**
@@ -468,6 +539,9 @@ class ProductScan {
 	 */
 	private function record( \WC_Product $product, array $rules, array $channel_rules, int $ids_limit ): void {
 		$failures = $this->failures( $product, $rules, $channel_rules );
+		if ( null !== $this->tested_callback ) {
+			( $this->tested_callback )( $product->get_id(), $failures );
+		}
 		foreach ( array_keys( $failures ) as $id ) {
 			$result = $this->result( $id );
 			++$result['count'];
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductsList.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductsList.php
new file mode 100644
index 00000000000..65d32ff602d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductsList.php
@@ -0,0 +1,322 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Keeping each product's stored failures current when it is saved, and the Products list
+ * filter behind Sales readiness' links to the products a check failed.
+ *
+ * @since 11.4.0
+ */
+class ProductsList {
+
+	public const FILTER_PARAM = 'agent_readiness';
+
+	/**
+	 * What a save may change without changing a check's result. A checkout saves products with only these.
+	 */
+	private const STOCK_ONLY_CHANGES = array( 'stock_quantity', 'stock_status', 'total_sales', 'date_modified' );
+
+	/**
+	 * Meta the brand and MPN checks read. A save can change only these, and get_changes() lists properties.
+	 */
+	private const READINESS_META = array( '_wc_gla_brand', '_wc_gla_mpn' );
+
+	/**
+	 * Features controller.
+	 *
+	 * @var FeaturesController
+	 */
+	private FeaturesController $features_controller;
+
+	/**
+	 * Readiness service.
+	 *
+	 * @var AgentReadiness
+	 */
+	private AgentReadiness $agent_readiness;
+
+	/**
+	 * Ids of products being saved with stock changes only.
+	 *
+	 * @var array<int, true>
+	 */
+	private array $stock_only_saves = array();
+
+	/**
+	 * Parent ids of variations being deleted, by variation id.
+	 *
+	 * @var array<int, int>
+	 */
+	private array $deleted_variation_parents = array();
+
+	/**
+	 * Parents to test again at the end of the request, as keys.
+	 *
+	 * @var array<int, true>
+	 */
+	private array $parents_to_retest = array();
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param FeaturesController $features_controller Features controller.
+	 * @param AgentReadiness     $agent_readiness     Readiness service.
+	 */
+	final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness ): void {
+		$this->features_controller = $features_controller;
+		$this->agent_readiness     = $agent_readiness;
+	}
+
+	/**
+	 * Register the hooks.
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_before_product_object_save', array( $this, 'handle_before_save' ) );
+		add_action( 'woocommerce_after_product_object_save', array( $this, 'handle_after_save' ) );
+		add_action( 'wp_trash_post', array( $this, 'handle_trash' ) );
+		add_action( 'untrashed_post', array( $this, 'handle_untrash' ) );
+		add_action( 'woocommerce_trash_product_variation', array( $this, 'handle_variation_removed' ) );
+		add_action( 'woocommerce_before_delete_product_variation', array( $this, 'handle_before_variation_delete' ) );
+		add_action( 'woocommerce_delete_product_variation', array( $this, 'handle_variation_removed' ) );
+		add_action( 'pre_get_posts', array( $this, 'apply_filter' ) );
+	}
+
+	/**
+	 * The Products list showing the products with a stored value.
+	 *
+	 * @param string $value Check id or channel value.
+	 */
+	public static function url( string $value ): string {
+		return add_query_arg(
+			array(
+				'post_type'        => 'product',
+				'post_status'      => 'publish',
+				self::FILTER_PARAM => $value,
+			),
+			admin_url( 'edit.php' )
+		);
+	}
+
+	/**
+	 * Note a save that changes only stock, before the changes are applied.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $product Product being saved.
+	 */
+	public function handle_before_save( $product ): void {
+		if ( ! $product instanceof \WC_Product ) {
+			return;
+		}
+		$changes = array_keys( $product->get_changes() );
+		if ( array() === array_diff( $changes, self::STOCK_ONLY_CHANGES ) && ! self::readiness_meta_changed( $product ) ) {
+			$this->stock_only_saves[ $product->get_id() ] = true;
+		}
+	}
+
+	/**
+	 * Whether this save changes the brand or MPN meta the checks read.
+	 *
+	 * @param \WC_Product $product Product being saved.
+	 */
+	private static function readiness_meta_changed( \WC_Product $product ): bool {
+		foreach ( self::READINESS_META as $key ) {
+			if ( $product->get_meta( $key ) !== get_post_meta( $product->get_id(), $key, true ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Update a saved product's stored failures. A variation updates its parent.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $product Saved product.
+	 */
+	public function handle_after_save( $product ): void {
+		if ( ! $product instanceof \WC_Product ) {
+			return;
+		}
+		$stock_only = isset( $this->stock_only_saves[ $product->get_id() ] );
+		unset( $this->stock_only_saves[ $product->get_id() ] );
+		if ( ! $stock_only ) {
+			$this->retest( $product );
+		}
+	}
+
+	/**
+	 * Store a product's failures again, once a scan has run. A variation updates its parent.
+	 *
+	 * @param \WC_Product $product Product.
+	 */
+	private function retest( \WC_Product $product ): void {
+		if ( ! $this->agent_readiness->has_report() || ! $this->enabled() ) {
+			return;
+		}
+		$product_id = $product instanceof \WC_Product_Variation ? $product->get_parent_id() : $product->get_id();
+		$product    = $this->reload( $product_id, $product );
+		if ( null === $product ) {
+			ProductFailures::forget( $product_id );
+			return;
+		}
+		$result = $this->agent_readiness->check_product( $product );
+		if ( null === $result ) {
+			ProductFailures::forget( $product_id );
+			return;
+		}
+		ProductFailures::store( $product_id, $result['checks'] );
+	}
+
+	/**
+	 * Load the product from the database.
+	 * The instance already in memory keeps variations that were removed in this request.
+	 *
+	 * @param int         $product_id Product id.
+	 * @param \WC_Product $product    Product currently in memory.
+	 */
+	private function reload( int $product_id, \WC_Product $product ): ?\WC_Product {
+		if ( $product_id <= 0 ) {
+			return null;
+		}
+		$current = $product instanceof \WC_Product_Variation ? wc_get_product( $product_id ) : $product;
+		$ids     = array( $product_id );
+		if ( $current instanceof \WC_Product_Variable ) {
+			$ids = array_merge( $ids, $current->get_children() );
+		}
+		ProductFields::forget_variations( $product_id );
+		delete_transient( 'wc_product_children_' . $product_id );
+		$cache = wc_get_container()->get( \Automattic\WooCommerce\Internal\Caches\ProductCache::class );
+		foreach ( $ids as $id ) {
+			$cache->remove( (int) $id );
+		}
+		$fresh = wc_get_product( $product_id );
+		return $fresh instanceof \WC_Product ? $fresh : null;
+	}
+
+	/**
+	 * Remove a trashed product's rows: the list and the counts show published products.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $post_id Post id.
+	 */
+	public function handle_trash( $post_id ): void {
+		if ( 'product' === get_post_type( $post_id ) ) {
+			ProductFailures::forget( (int) $post_id );
+		}
+	}
+
+	/**
+	 * Test a restored product again.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $post_id Post id.
+	 */
+	public function handle_untrash( $post_id ): void {
+		$type = get_post_type( $post_id );
+		if ( 'product' === $type ) {
+			$this->handle_after_save( wc_get_product( (int) $post_id ) );
+		} elseif ( 'product_variation' === $type ) {
+			$this->handle_variation_removed( $post_id );
+		}
+	}
+
+	/**
+	 * Note a variation's parent before the variation is deleted, when it can still be read.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $variation_id Variation id.
+	 */
+	public function handle_before_variation_delete( $variation_id ): void {
+		$this->deleted_variation_parents[ (int) $variation_id ] = (int) wp_get_post_parent_id( (int) $variation_id );
+	}
+
+	/**
+	 * Note a parent to test again after one of its variations is trashed, restored or deleted.
+	 * Removing a variation doesn't save the parent. Removing many fires this once each, so the
+	 * parent is tested once, at the end of the request.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $variation_id Variation id.
+	 */
+	public function handle_variation_removed( $variation_id ): void {
+		$variation_id = (int) $variation_id;
+		$parent_id    = $this->deleted_variation_parents[ $variation_id ] ?? (int) wp_get_post_parent_id( $variation_id );
+		unset( $this->deleted_variation_parents[ $variation_id ] );
+		if ( $parent_id <= 0 ) {
+			return;
+		}
+		if ( false === has_action( 'shutdown', array( $this, 'retest_parents' ) ) ) {
+			add_action( 'shutdown', array( $this, 'retest_parents' ) );
+		}
+		$this->parents_to_retest[ $parent_id ] = true;
+	}
+
+	/**
+	 * Test the noted parents again. A parent deleted in the same request has its rows removed,
+	 * so a deleted variable product doesn't keep counting.
+	 *
+	 * @internal
+	 */
+	public function retest_parents(): void {
+		$parent_ids              = array_keys( $this->parents_to_retest );
+		$this->parents_to_retest = array();
+		foreach ( $parent_ids as $parent_id ) {
+			$parent = 'product' === get_post_type( $parent_id ) ? wc_get_product( $parent_id ) : null;
+			if ( $parent instanceof \WC_Product ) {
+				$this->retest( $parent );
+			} else {
+				ProductFailures::forget( $parent_id );
+			}
+		}
+	}
+
+	/**
+	 * Limit the Products list to the chosen problem.
+	 *
+	 * @internal
+	 *
+	 * @param mixed $query Query.
+	 */
+	public function apply_filter( $query ): void {
+		global $pagenow;
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter that changes nothing.
+		if ( ! $query instanceof \WP_Query || ! is_admin() || 'edit.php' !== $pagenow || ! $query->is_main_query() || 'product' !== $query->get( 'post_type' ) || ! $this->enabled() || empty( $_GET[ self::FILTER_PARAM ] ) ) {
+			return;
+		}
+		// The list's own meta query stays a group of its own, so an OR in it can't widen this filter.
+		$existing   = array_filter( (array) $query->get( 'meta_query' ) );
+		$meta_query = array(
+			'relation' => 'AND',
+			array(
+				'key'   => ProductFailures::META_KEY,
+				// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter that changes nothing.
+				'value' => sanitize_text_field( wp_unslash( $_GET[ self::FILTER_PARAM ] ) ),
+			),
+		);
+		if ( array() !== $existing ) {
+			$meta_query[] = $existing;
+		}
+		$query->set( 'meta_query', $meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+	}
+
+	/**
+	 * Whether the feature is on.
+	 */
+	private function enabled(): bool {
+		return $this->features_controller->feature_is_enabled( AgenticCommerceSettings::FEATURE_ID );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 5392d5ca8ff..836dc5522d4 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -86,6 +86,16 @@ A check listed in `StoreOutputChecks::PENDING` tests output WooCommerce doesn't

 robots.txt is loaded from the host root, only to find the sitemaps it lists. Its crawler rules aren't checked: a host or CDN can rewrite the file or block crawlers without it, and the store's request to itself skips both, so they wait for an outside view of the store (WOOPRD-3953). The sitemap check is "couldn't check" when robots.txt could not be read, or a candidate sitemap could not be loaded or answered other than 200, 404 or 410, and no fetched sitemap names products. A category sitemap does not count. When an index does not name products, its child sitemaps on this host are checked, nested indexes included (Jetpack serves one past 1,000 URLs), up to five child requests in all. Sitemap lines on another host, or written as `//host/path`, are skipped and do not use up the five listed slots. Checks on the home and product page read the ones that loaded. The home page loads first; when it can't be reached, the scan makes no more requests, so a store that can't reach itself waits for one timeout. The product checks load one product page: among the five newest published simple or variable products in the shop catalog, the newest with a price and no password, or the newest without a password when none of them has a price. With no such product, or when its page doesn't answer 200, they are "couldn't check".

+## Failures per product
+
+`refresh_report()` stores what each product fails in product meta (`ProductFailures`), one row per check and per channel status, plus a `checked` row on every product the checks cover, passing ones included. `ProductsList` updates a product's rows when it is saved, except for a save that changes only stock or nothing, removes them when it is trashed, tests the parent again when a variation is trashed, restored or deleted, and filters the Products list on them for the links from the readiness pages (`?agent_readiness=<value>`); there is no column or dropdown on the list. A covered product without rows was not tested, by the scan's product limit. The stored report's product counts are read from these rows, the total from the `checked` rows, so a product fixed, added or removed since the scan counts now. The example products per check can be out of date; the count links to the filtered Products list. The scan stores the GTINs more than one product shares in the report (`shared_gtins`, not returned by the report readers, up to `ProductScan::SHARED_GTINS_LIMIT`), so checking one product on save or with the run ability doesn't read every published product. Past the limit, nothing is stored and a check of one product reads the catalog. A GTIN shared since the scan, on the saved product or another, is flagged at the next scan. Core refuses an exact duplicate on save, so this is one that differs only by separators or leading zeros, or a direct meta write.
+
+## What the store declares to agents
+
+`DeclaredToAgents` lists protocols and files as found or not found, for information. Each has a path or signal from its spec, and a JSON document has to have the fields its spec requires. Its results go into the report's `declared` and never count as a problem.
+
+Markdown is asked for on the product page, or the home page without a product: with `Accept: text/markdown`. An HTML answer doesn't count. A copy at the page's `.md` address isn't requested: each declared item reads a response the scan already loads. `Vary: Accept` isn't listed: hosts and CDNs set it, and the store's request to itself skips them.
+
 ## Report format

 `AgentReadiness::get_report()` returns:
@@ -101,6 +111,10 @@ array(
 		// ok: true, false, or null. Null means the page could not be loaded, or a product field check found no Product JSON-LD (the settings page hides that).
 		array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'level' => 'required', 'name' => '…', 'label' => '…', 'why' => '…', 'fix' => '…', 'fix_url' => '…' ),
 	),
+	// What the store declares to agents, for information: found is true, false, or null when it couldn't be checked.
+	'declared' => array(
+		array( 'id' => 'security-txt', 'name' => '…', 'found' => false ),
+	),
 	'channels'     => array(
 		'chatgpt' => array(
 			'name'         => 'ChatGPT',
@@ -146,9 +160,9 @@ Per check, `channels` maps a channel id to the feed field and the effect, the sa

 "Run check" on the Agentic commerce settings page, or `POST wc/v3/agent-readiness/scan`, queues the scan in Action Scheduler (`ScheduledScan`). The report is stored in the `woocommerce_agent_readiness_report` option (not autoloaded). Each run schedules the next one a day later, before scanning, so a failed scan doesn't stop the daily refresh; a run that finds the feature disabled does. If a queued scan still hasn't started 10 minutes after it was due and no other scheduled action has finished, failed or started in that time (Action Scheduler isn't running), the settings page says so and the scan stays queued. The scan never runs in a page or REST request; splitting the product pass into one action per batch is WOOAI-1661.

-Product changes don't update the stored report: a fixed product leaves it at the next daily scan or "Run check". On a store with 11,000 products, a scan (stopping at 5,000) took about 4 seconds and 144 MB.
+On a store with 11,000 products, a scan (stopping at 5,000) took about 4 seconds and 144 MB. A product saved since the scan is already reflected in the counts, as described under Failures per product.

-`GET wc/v3/agent-readiness` returns `{ "report": <report or null>, "scan_pending": <bool>, "scan_stalled": <bool> }`, with `store_checks` read now; `scan_stalled` is true while a queued scan waits on a stalled Action Scheduler. Both routes need `manage_woocommerce` and exist only while the feature is enabled.
+`GET wc/v3/agent-readiness` returns `{ "report": <report or null>, "scan_pending": <bool>, "scan_stalled": <bool> }` from `AgentReadiness::get_served_report()`, so `store_checks` are read now; `scan_stalled` is true while a queued scan waits on a stalled Action Scheduler. Both routes need `manage_woocommerce` and exist only while the feature is enabled.

 The same shape is meant for every consumer: the Agentic commerce settings page, an outside scan of a store, and extensions that rank the findings by sales or offer fixes. Check and channel ids are stable keys.

@@ -158,7 +172,7 @@ The scan walks published simple and variable products shown in the shop catalog

 While the feature is enabled, `AbilitiesLoader` registers these abilities in the WordPress Abilities API with the `Ability` class, which converts their input to the schema types. Each definition class in this directory owns its arguments. They show in REST and to MCP clients, for users with `manage_woocommerce`:

-- `woocommerce/get-agent-readiness` and `GET wc/v3/agent-readiness` both return `{ report, scan_pending, scan_stalled }` with `store_checks` read now (`AgentReadiness::get_store_checks()`): settings changed since the scan show their current state, and store output checks come from the stored report.
+- `woocommerce/get-agent-readiness` and `GET wc/v3/agent-readiness` both return `{ report, scan_pending, scan_stalled }` from `AgentReadiness::get_served_report()`: settings changed since the scan show their current state, and store output checks come from the stored report.
 - `woocommerce/run-agent-readiness-check` with `product_id` checks that product now (`check_product()`) and returns its failing checks with label and fix, and its status per channel. Without one it queues the background scan, like `POST wc/v3/agent-readiness/scan`, and returns `scan_pending`, `scan_stalled` and `report_generated_at`, so the agent knows when to read the report again, or that it won't change until scheduled actions run.

 ## Agentic commerce settings page
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
index ba4847036a7..73adc04c05d 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
@@ -134,13 +134,9 @@ class ReadinessRestController extends RestApiControllerBase {
 	 * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
 	 */
 	protected function get_status( WP_REST_Request $request ): array {
-		$report = $this->agent_readiness->get_cached_report();
-		if ( null !== $report ) {
-			$report['store_checks'] = $this->agent_readiness->get_store_checks( $report );
-		}
 		$pending = $this->scheduled_scan->is_pending();
 		return array(
-			'report'       => $report,
+			'report'       => $this->agent_readiness->get_served_report(),
 			'scan_pending' => $pending,
 			'scan_stalled' => $pending && $this->scheduled_scan->is_stalled(),
 		);
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
index f830864b333..e7125f641c7 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
@@ -72,13 +72,9 @@ class ReportAbility implements AbilityDefinition {
 		unset( $input );
 		$readiness = wc_get_container()->get( AgentReadiness::class );
 		$scan      = wc_get_container()->get( ScheduledScan::class );
-		$report    = $readiness->get_cached_report();
-		if ( null !== $report ) {
-			$report['store_checks'] = $readiness->get_store_checks( $report );
-		}
-		$pending = $scan->is_pending();
+		$pending   = $scan->is_pending();
 		return array(
-			'report'       => $report,
+			'report'       => $readiness->get_served_report(),
 			'scan_pending' => $pending,
 			'scan_stalled' => $pending && $scan->is_stalled(),
 		);
@@ -181,6 +177,18 @@ class ReportAbility implements AbilityDefinition {
 								),
 							),
 						),
+						'declared'            => array(
+							'type'        => 'array',
+							'description' => __( 'Protocols and files the store declares to agents. Information only: found or not found, never a problem.', 'woocommerce' ),
+							'items'       => array(
+								'type'       => 'object',
+								'properties' => array(
+									'id'    => array( 'type' => 'string' ),
+									'name'  => array( 'type' => 'string' ),
+									'found' => array( 'type' => array( 'boolean', 'null' ) ),
+								),
+							),
+						),
 						'truncated'           => array( 'type' => 'boolean' ),
 					),
 				),
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
index f9920a8ef66..077470c1d31 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
@@ -42,12 +42,30 @@ class StoreOutputChecks {
 	}

 	/**
-	 * Run every check. Each URL is requested at most once per call.
+	 * The responses of one scan, shared by everything that reads the store during it.
+	 */
+	public function responses(): StoreResponses {
+		return new StoreResponses( $this->coming_soon_helper->is_site_live() );
+	}
+
+	/**
+	 * Run every check. Each URL is requested at most once.
 	 *
 	 * @return array<int, array{id:string,ok:bool|null,blocks:string,group:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,conflict:bool,pending:bool}>
 	 */
 	public function all(): array {
-		$responses = new StoreResponses( $this->coming_soon_helper->is_site_live() );
+		return $this->all_for( $this->responses() );
+	}
+
+	/**
+	 * Run every check against one scan's responses, so other checks can read the same pages.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param StoreResponses $responses Responses of the scan.
+	 * @return array<int, array{id:string,ok:bool|null,blocks:string,group:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,conflict:bool,pending:bool}>
+	 */
+	public function all_for( StoreResponses $responses ): array {
 		// The home page first: when it can't be reached, no other request is made.
 		$responses->home_page();
 		$results = array();
@@ -108,7 +126,7 @@ class StoreOutputChecks {
 	/**
 	 * Organization and the subtypes stores use for themselves.
 	 */
-	private const ORGANIZATION_TYPES = array( 'Organization', 'OnlineStore', 'OnlineBusiness', 'Corporation', 'LocalBusiness', 'Store' );
+	public const ORGANIZATION_TYPES = array( 'Organization', 'OnlineStore', 'OnlineBusiness', 'Corporation', 'LocalBusiness', 'Store' );

 	/**
 	 * The checks, each with a test that returns true, false, or null for couldn't check.
@@ -365,7 +383,7 @@ class StoreOutputChecks {
 	 *
 	 * @param string $path Path from the host root, such as /llms.txt.
 	 */
-	private static function host_root( string $path ): string {
+	public static function host_root( string $path ): string {
 		$home = (array) wp_parse_url( home_url() );
 		return ( $home['scheme'] ?? 'http' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ) . $path;
 	}
@@ -391,16 +409,13 @@ class StoreOutputChecks {
 	}

 	/**
-	 * Whether a sitemap the store serves names products. An index that does not
-	 * has its child sitemaps on this host opened, at any depth, up to five child
-	 * requests in all. Null when robots.txt or one of those sitemaps could not
-	 * be loaded and none named products.
+	 * Where a store's sitemaps may be: those robots.txt lists on this host (the first five), then the usual paths.
 	 *
-	 * @param StoreResponses $responses Store responses.
+	 * @param string $robots The robots.txt text.
+	 * @return string[]
 	 */
-	private function sitemap_lists_products( StoreResponses $responses ): ?bool {
-		$robots   = $this->robots_txt( $responses );
-		$listed   = null === $robots ? array() : array_slice(
+	public static function sitemap_urls( string $robots ): array {
+		$listed   = array_slice(
 			array_values( array_filter( ( new RobotsTxt( $robots ) )->sitemaps(), array( StoreResponses::class, 'is_store_url' ) ) ),
 			0,
 			self::MAX_LISTED_SITEMAPS
@@ -409,7 +424,20 @@ class StoreOutputChecks {
 		if ( function_exists( 'wp_sitemaps_get_server' ) && wp_sitemaps_get_server()->sitemaps_enabled() ) {
 			$defaults[] = '/wp-sitemap.xml';
 		}
-		$queue   = array_values( array_unique( array_map( array( self::class, 'absolute_url' ), array_merge( $listed, $defaults ) ) ) );
+		return array_values( array_unique( array_merge( $listed, $defaults ) ) );
+	}
+
+	/**
+	 * Whether a sitemap the store serves names products. An index that does not
+	 * has its child sitemaps on this host opened, at any depth, up to five child
+	 * requests in all. Null when robots.txt or one of those sitemaps could not
+	 * be loaded and none named products.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private function sitemap_lists_products( StoreResponses $responses ): ?bool {
+		$robots  = $this->robots_txt( $responses );
+		$queue   = array_values( array_unique( array_map( array( self::class, 'absolute_url' ), self::sitemap_urls( (string) $robots ) ) ) );
 		$seen    = array_fill_keys( $queue, true );
 		$budget  = count( $queue ) + self::MAX_CHILD_SITEMAPS;
 		$unknown = null === $robots;
@@ -676,9 +704,11 @@ class StoreOutputChecks {
 	 * page is missing: a server error, or a firewall or security plugin answering 401 or 403.
 	 * A check then reports couldn't check, as it does when the page times out.
 	 *
+	 * @since 11.4.0
+	 *
 	 * @param int $status HTTP status.
 	 */
-	private static function blocked( int $status ): bool {
+	public static function blocked( int $status ): bool {
 		return $status >= 500 || in_array( $status, array( 401, 403 ), true );
 	}

diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
index 03458ced75c..a27b6234bff 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
@@ -88,6 +88,21 @@ class StoreResponses {
 		return $this->responses[ $url ];
 	}

+	/**
+	 * The response when a URL on the store's host is asked for as Markdown. Redirects are not followed.
+	 *
+	 * @param string $url URL or path.
+	 * @return array{status:int,body:string,headers:array<string,string>}|null Null when the store could not be reached.
+	 */
+	public function get_as_markdown( string $url ): ?array {
+		$url = self::absolute( $url );
+		$key = 'MARKDOWN ' . $url;
+		if ( ! array_key_exists( $key, $this->responses ) ) {
+			$this->responses[ $key ] = $this->site_live && $this->is_store_url( $url ) ? $this->request( $url, array( 'headers' => array( 'Accept' => 'text/markdown' ) ) ) : null;
+		}
+		return $this->responses[ $key ];
+	}
+
 	/**
 	 * The response to a JSON POST to a URL on the store's host, such as a UCP endpoint. Redirects are not followed.
 	 *
@@ -245,14 +260,15 @@ class StoreResponses {
 		);
 		if ( is_wp_error( $response ) ) {
 			// When the home page can't be reached, other URLs would fail the same way, so a scan
-			// of a store that can't reach itself waits for one timeout, not one per URL.
-			if ( untrailingslashit( $url ) === untrailingslashit( home_url( '/' ) ) ) {
+			// of a store that can't reach itself waits for one timeout, not one per URL. Only the
+			// plain GET counts: a Markdown or POST request can fail where the page itself loads.
+			if ( array() === $args && untrailingslashit( $url ) === untrailingslashit( home_url( '/' ) ) ) {
 				$this->reachable = false;
 			}
 			return null;
 		}
 		$headers = array();
-		foreach ( array( 'location', 'x-robots-tag' ) as $name ) {
+		foreach ( array( 'location', 'x-robots-tag', 'content-type', 'vary' ) as $name ) {
 			$value = wp_remote_retrieve_header( $response, $name );
 			$value = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
 			if ( '' !== $value ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
index fa6a1783a40..522f96af5dc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;

 use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
 use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
 use Automattic\WooCommerce\Internal\AgentReadiness\RemoteRules;
 use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
@@ -37,7 +38,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
 		$output_checks = new StoreOutputChecks();
 		$output_checks->init( new ComingSoonHelper() );
 		$this->sut = new AgentReadiness();
-		$this->sut->init( $store_conditions, $output_checks, new RemoteRules() );
+		$this->sut->init( $store_conditions, $output_checks, new RemoteRules(), wc_get_container()->get( \Automattic\WooCommerce\Internal\AgentReadiness\DeclaredToAgents::class ) );
 	}

 	/**
@@ -74,7 +75,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
 		$output_checks->init( new ComingSoonHelper() );
 		$store_conditions = new StoreConditions();
 		$store_conditions->init( new ComingSoonHelper() );
-		$this->sut->init( $store_conditions, $output_checks, new RemoteRules() );
+		$this->sut->init( $store_conditions, $output_checks, new RemoteRules(), wc_get_container()->get( \Automattic\WooCommerce\Internal\AgentReadiness\DeclaredToAgents::class ) );
 		add_filter( 'pre_http_request', static fn() => new \WP_Error( 'http_request_failed', 'Timed out' ) );

 		$checks = array_column( $this->sut->get_report()['store_checks'], null, 'id' );
@@ -96,7 +97,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {

 		$report = $this->sut->get_report();

-		$this->assertSame( array( 'generated_at', 'products', 'hidden_from_catalog', 'unsupported_types', 'store_checks', 'channels', 'checks', 'truncated' ), array_keys( $report ) );
+		$this->assertSame( array( 'generated_at', 'products', 'hidden_from_catalog', 'unsupported_types', 'store_checks', 'declared', 'channels', 'checks', 'truncated' ), array_keys( $report ) );
 		$this->assertFalse( $report['truncated'] );
 		$this->assertSame( array( 'id', 'ok', 'blocks', 'level', 'name', 'label', 'why', 'fix', 'fix_url' ), array_keys( $report['store_checks'][0] ) );
 		$this->assertSame( array( 'chatgpt', 'google', 'copilot' ), array_keys( $report['channels'] ) );
@@ -275,6 +276,47 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'duplicate_gtin', $this->sut->check_product( $first )['checks'] );
 	}

+	/**
+	 * @testdox Past the limit, the scan stores no shared GTINs, so checking one product reads the catalog.
+	 */
+	public function test_shared_gtins_past_the_limit(): void {
+		$scan = new ProductScan();
+		$scan->use_shared_gtins( range( 1, ProductScan::SHARED_GTINS_LIMIT ) );
+		$this->assertCount( ProductScan::SHARED_GTINS_LIMIT, $scan->shared_gtins() );
+
+		$scan->use_shared_gtins( range( 1, ProductScan::SHARED_GTINS_LIMIT + 1 ) );
+		$this->assertNull( $scan->shared_gtins() );
+	}
+
+	/**
+	 * @testdox After a stored scan, checking one product reads the scan's shared GTINs instead of every published product, and the report doesn't show them.
+	 */
+	public function test_check_product_reads_stored_shared_gtins(): void {
+		StoreOutputChecksTest::block_requests();
+		$first = WC_Helper_Product::create_simple_product();
+		$first->set_global_unique_id( '4006381333931' );
+		$first->save();
+		$second_id = WC_Helper_Product::create_simple_product()->get_id();
+		update_post_meta( $second_id, '_global_unique_id', '4006381333931' );
+		$this->sut->refresh_report();
+
+		$this->assertSame( array( '4006381333931' ), get_option( AgentReadiness::REPORT_OPTION )['shared_gtins'] );
+		$this->assertArrayNotHasKey( 'shared_gtins', $this->sut->get_cached_report() );
+
+		delete_post_meta( $second_id, '_global_unique_id' );
+		$queries = 0;
+		$count   = static function ( $query ) use ( &$queries ) {
+			$queries += false !== strpos( (string) $query, 'HAVING COUNT(*) > 1' ) ? 1 : 0;
+			return $query;
+		};
+		add_filter( 'query', $count );
+		$checks = $this->sut->check_product( $first )['checks'];
+		remove_filter( 'query', $count );
+
+		$this->assertArrayHasKey( 'duplicate_gtin', $checks, 'A GTIN change on another product waits for the next scan' );
+		$this->assertSame( 0, $queries );
+	}
+
 	/**
 	 * @testdox Products hidden from the shop catalog and unsupported types are counted, not checked.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
index 83494e9acaf..0cc4e565603 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
@@ -327,6 +327,62 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
 		$this->assertMatchesRegularExpression( '#<details>.*sitemap_lists_products name.*</details>#s', $html );
 	}

+	/**
+	 * @testdox The declared list shows found and not found as information, and isn't counted in the summary.
+	 */
+	public function test_output_declared_block(): void {
+		update_option(
+			'woocommerce_agent_readiness_report',
+			array(
+				'generated_at' => gmdate( 'c' ),
+				'store_checks' => array(),
+				'declared'     => array(
+					array(
+						'id'    => 'ai-txt',
+						'name'  => 'ai.txt usage file',
+						'found' => true,
+					),
+					array(
+						'id'    => 'agents-md',
+						'name'  => 'AGENTS.md',
+						'found' => false,
+					),
+				),
+			)
+		);
+
+		$html = $this->render_page();
+
+		$this->assertStringContainsString( '1 of 2 found', $html );
+		$this->assertStringContainsString( 'Not found', $html );
+		$this->assertStringNotContainsString( 'AGENTS.md</td><td>Fix', $html );
+		$this->assertMatchesRegularExpression( '#Can they find your store\?</a>\s*Run check to find out#', $html, 'Not counted as a result' );
+	}
+
+	/**
+	 * @testdox The declared summary says what couldn't be checked, and a bad stored entry doesn't break the page.
+	 */
+	public function test_output_declared_unknown_and_bad_entries(): void {
+		$report = static fn( array $declared ): array => array(
+			'generated_at' => gmdate( 'c' ),
+			'store_checks' => array(),
+			'declared'     => $declared,
+		);
+		$item   = static fn( ?bool $found ): array => array(
+			'id'    => 'ai-txt',
+			'name'  => 'ai.txt usage file',
+			'found' => $found,
+		);
+
+		update_option( 'woocommerce_agent_readiness_report', $report( array( $item( null ), $item( null ), 'not an array' ) ) );
+		$html = $this->render_page();
+		$this->assertStringContainsString( '<summary>Not checked</summary>', $html );
+		$this->assertStringNotContainsString( '0 of', $html );
+
+		update_option( 'woocommerce_agent_readiness_report', $report( array( $item( true ), $item( false ), $item( null ) ) ) );
+		$this->assertStringContainsString( '1 of 3 found, 1 not checked', $this->render_page() );
+	}
+
 	/**
 	 * @testdox Every question reads the scan, so without one each asks for it; pending checks don't count as unchecked.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/DeclaredToAgentsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/DeclaredToAgentsTest.php
new file mode 100644
index 00000000000..e193fe1dbec
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/DeclaredToAgentsTest.php
@@ -0,0 +1,461 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\DeclaredToAgents;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreOutputChecks;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the declared-to-agents list.
+ */
+class DeclaredToAgentsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Run after each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'pre_http_request' );
+		delete_option( 'woocommerce_coming_soon' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Results by id, with each path answering as given and any other path a 404.
+	 *
+	 * @param array<string, array{0:int,1:string,2?:array<string,string>}> $responses Status, body and headers by path; 'markdown /path' for a Markdown request.
+	 * @return array<string, bool|null>
+	 */
+	private function results( array $responses ): array {
+		update_option( 'woocommerce_coming_soon', 'no' );
+		add_filter(
+			'pre_http_request',
+			static function ( $pre, $args, $url ) use ( $responses ) {
+				unset( $pre );
+				$path     = substr( $url, strlen( untrailingslashit( home_url() ) ) );
+				$markdown = 'text/markdown' === ( $args['headers']['Accept'] ?? '' );
+				$response = $responses[ ( $markdown ? 'markdown ' : '' ) . $path ] ?? array( 404, '' );
+				return array(
+					'headers'  => $response[2] ?? array(),
+					'body'     => $response[1],
+					'response' => array(
+						'code'    => $response[0],
+						'message' => '',
+					),
+					'cookies'  => array(),
+				);
+			},
+			10,
+			3
+		);
+		return array_column( $this->scan(), 'found', 'id' );
+	}
+
+	/**
+	 * The list from one scan.
+	 *
+	 * @return array<int, array{id:string,name:string,found:bool|null}>
+	 */
+	private function scan(): array {
+		return wc_get_container()->get( DeclaredToAgents::class )->all( wc_get_container()->get( StoreOutputChecks::class )->responses() );
+	}
+
+	/**
+	 * @testdox A 401 or 403 is not checked: a firewall can refuse a file that exists.
+	 */
+	public function test_blocked_response_is_not_checked(): void {
+		$results = $this->results(
+			array(
+				'/ai.txt'                      => array( 403, 'Forbidden' ),
+				'/.well-known/agent-card.json' => array( 401, '' ),
+				'/feed/'                       => array( 403, '' ),
+				'markdown /'                   => array( 403, '' ),
+				'/sitemap.xml'                 => array( 403, '' ),
+			)
+		);
+
+		$this->assertNull( $results['ai-txt'] );
+		$this->assertNull( $results['a2a-agent-card'] );
+		$this->assertNull( $results['feed-present'] );
+		$this->assertNull( $results['markdown-pages'] );
+		$this->assertNull( $results['sitemap-lastmod'] );
+	}
+
+	/**
+	 * @testdox A file counts as found; a 404, a page a theme answers with, or a 200 error a host answers with does not.
+	 */
+	public function test_files(): void {
+		$results = $this->results(
+			array(
+				'/ai.txt'                  => array( 200, 'User-Agent: *' ),
+				'/.well-known/agents.json' => array( 200, '<!doctype html><html></html>' ),
+				'/AGENTS.md'               => array( 200, '{"code":"not_found","message":"No route"}' ),
+				'/auth.md'                 => array( 200, 'Not found', array( 'content-type' => 'text/html; charset=UTF-8' ) ),
+				'/sitemap.md'              => array( 200, "\xEF\xBB\xBF<!doctype html><html></html>" ),
+			)
+		);
+
+		$this->assertTrue( $results['ai-txt'] );
+		$this->assertFalse( $results['agents-json'], 'A page is not a JSON document' );
+		$this->assertFalse( $results['security-txt'], 'A 404' );
+		$this->assertFalse( $results['agents-md'], 'A 200 JSON error is not a file' );
+		$this->assertFalse( $results['auth-md'], 'A 200 HTML answer is not a file' );
+		$this->assertFalse( $results['sitemap-md'], 'A page behind a byte order mark is not a file' );
+	}
+
+	/**
+	 * @testdox A JSON document counts only when it has what its spec requires, so a JSON error on any path is not found.
+	 */
+	public function test_documents(): void {
+		$error   = array( 200, '{"code":"not_found","message":"No route"}' );
+		$results = $this->results(
+			array(
+				'/.well-known/agent-card.json'            => $error,
+				'/openapi.json'                           => $error,
+				'/.well-known/oauth-authorization-server' => $error,
+				'/.well-known/oauth-protected-resource'   => array( 200, '{"resource":"https://example.org"}' ),
+				'/.well-known/agent-skills/index.json'    => array( 200, '{"skills":[{"name":"a"}]}' ),
+				'/.well-known/http-message-signatures-directory' => array( 200, '{"keys":[{"kty":"OKP"}]}' ),
+				'/.well-known/tdmrep.json'                => array( 200, '[{"location":"/","tdm-reservation":1}]' ),
+				'/.well-known/agents.json'                => array( 503, '' ),
+			)
+		);
+
+		$this->assertFalse( $results['a2a-agent-card'] );
+		$this->assertFalse( $results['openapi-spec'] );
+		$this->assertFalse( $results['oauth-discovery'] );
+		$this->assertTrue( $results['oauth-protected-resource'] );
+		$this->assertTrue( $results['agent-skills-index'] );
+		$this->assertTrue( $results['web-bot-auth'] );
+		$this->assertTrue( $results['tdmrep'] );
+		$this->assertNull( $results['agents-json'], 'A server error is not checked' );
+
+		remove_all_filters( 'pre_http_request' );
+		$this->assertFalse( $this->results( array( '/.well-known/agents.json' => $error ) )['agents-json'] );
+		remove_all_filters( 'pre_http_request' );
+		$this->assertTrue( $this->results( array( '/.well-known/agents.json' => array( 200, '{"agentsJson":"0.1.0","flows":[{"id":"a"}]}' ) ) )['agents-json'] );
+	}
+
+	/**
+	 * @testdox Protocol documents count only with the fields their spec requires.
+	 */
+	public function test_protocol_documents(): void {
+		$host    = wp_parse_url( home_url(), PHP_URL_HOST );
+		$results = $this->results(
+			array(
+				'/.well-known/poppy.json'      => array(
+					200,
+					wp_json_encode(
+						array(
+							'protocol_version' => '0.3',
+							'organization'     => array( 'domain' => $host ),
+						)
+					),
+				),
+				'/.well-known/acp.json'        => array( 200, '{"protocol":{"name":"other"}}' ),
+				'/.well-known/ard.json'        => array( 200, '{"entries":[]}' ),
+				'/.well-known/ai-catalog.json' => array( 200, '{"entries":[]}' ),
+				'/openapi.json'                => array( 200, '{"openapi":"3.1.0","paths":{"/a":{"get":{"x-payment-info":{}}}}}' ),
+			)
+		);
+
+		$this->assertTrue( $results['poppy-json'] );
+		$this->assertFalse( $results['acp-support'] );
+		$this->assertTrue( $results['ard-manifest'] );
+		$this->assertFalse( $results['ai-catalog'], 'specVersion is required' );
+		$this->assertTrue( $results['mpp'] );
+		$this->assertTrue( $results['openapi-spec'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results(
+			array(
+				'/.well-known/poppy.json' => array( 200, '{"protocol_version":"0.3","organization":{"domain":"other.example"}}' ),
+				'/openapi.json'           => array( 200, '{"openapi":"3.1.0","paths":{"/a":{"get":{}}}}' ),
+			)
+		);
+		$this->assertFalse( $results['poppy-json'], 'Another domain' );
+		$this->assertFalse( $results['mpp'] );
+	}
+
+	/**
+	 * @testdox WebMCP needs a form with both attributes, and a page that fails to load is not checked.
+	 */
+	public function test_webmcp_forms(): void {
+		$this->assertTrue( $this->results( array( '/' => array( 200, '<form id="a" toolname="search" tooldescription="Find"></form>' ) ) )['webmcp-forms-schema'] );
+		remove_all_filters( 'pre_http_request' );
+		$this->assertFalse( $this->results( array( '/' => array( 200, '<form toolname="search"></form>' ) ) )['webmcp-forms-schema'] );
+		remove_all_filters( 'pre_http_request' );
+		$this->assertNull( $this->results( array( '/' => array( 503, '' ) ) )['webmcp-forms-schema'] );
+		remove_all_filters( 'pre_http_request' );
+		$product = \WC_Helper_Product::create_simple_product();
+		$path    = substr( (string) $product->get_permalink(), strlen( untrailingslashit( home_url() ) ) );
+		$this->assertTrue(
+			$this->results(
+				array(
+					'/'   => array( 200, '<html></html>' ),
+					$path => array( 200, '<form toolname="buy" tooldescription="Buy"></form>' ),
+				)
+			)['webmcp-forms-schema'],
+			'On the product page only'
+		);
+	}
+
+	/**
+	 * @testdox Declarations inside an HTML comment or a script are not found: only real tags count.
+	 */
+	public function test_commented_out_declarations(): void {
+		$results = $this->results(
+			array(
+				'/' => array(
+					200,
+					'<html><head><!-- <link rel="alternate" type="text/markdown" href="/index.md"> --></head><body>'
+					. '<!-- <form toolname="search" tooldescription="Find"></form> -->'
+					. '<script>var a = \'<pre class="language-php">\';</script>'
+					. '<!-- <input type="search" name="s" role="combobox"> --></body></html>',
+				),
+			)
+		);
+		$this->assertFalse( $results['webmcp-forms-schema'] );
+		$this->assertFalse( $results['markdown-alternate-link'] );
+		$this->assertFalse( $results['code-block-language'] );
+		$this->assertFalse( $results['site-search-autocomplete'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( '/' => array( 200, '<form><input type="search" name="s" aria-autocomplete="none"></form>' ) ) );
+		$this->assertFalse( $results['site-search-autocomplete'], 'aria-autocomplete="none" turns it off' );
+	}
+
+	/**
+	 * @testdox lastmod is read from a sitemap or from the first sitemap an index lists.
+	 */
+	public function test_sitemap_lastmod(): void {
+		$child = home_url( '/products.xml' );
+		$this->assertTrue( $this->results( array( '/sitemap.xml' => array( 200, '<urlset><url><loc>a</loc><lastmod>2026-01-01</lastmod></url></urlset>' ) ) )['sitemap-lastmod'] );
+		remove_all_filters( 'pre_http_request' );
+		$this->assertFalse( $this->results( array( '/sitemap.xml' => array( 200, '<urlset><url><loc>a</loc></url></urlset>' ) ) )['sitemap-lastmod'] );
+		remove_all_filters( 'pre_http_request' );
+		$this->assertTrue(
+			$this->results(
+				array(
+					'/sitemap.xml'  => array( 200, "<sitemapindex><sitemap><loc>$child</loc></sitemap></sitemapindex>" ),
+					'/products.xml' => array( 200, '<urlset><url><loc>a</loc><lastmod>2026-01-01</lastmod></url></urlset>' ),
+				)
+			)['sitemap-lastmod']
+		);
+	}
+
+	/**
+	 * @testdox lastmod is not found on a sitemap without dates, and not checked when a sitemap or robots.txt fails.
+	 */
+	public function test_sitemap_lastmod_unknown(): void {
+		$child = home_url( '/products.xml' );
+		$index = array( 200, "<sitemapindex><sitemap><loc>$child</loc></sitemap></sitemapindex>" );
+		$this->assertFalse(
+			$this->results(
+				array(
+					'/sitemap.xml'  => $index,
+					'/products.xml' => array( 200, '<urlset><url><loc>a</loc></url></urlset>' ),
+				)
+			)['sitemap-lastmod']
+		);
+		remove_all_filters( 'pre_http_request' );
+		$this->assertNull(
+			$this->results(
+				array(
+					'/sitemap.xml'  => $index,
+					'/products.xml' => array( 503, '' ),
+				)
+			)['sitemap-lastmod']
+		);
+		remove_all_filters( 'pre_http_request' );
+		$this->assertNull( $this->results( array( '/robots.txt' => array( 403, '' ) ) )['sitemap-lastmod'] );
+	}
+
+	/**
+	 * @testdox A Markdown version is found by Accept and from a link, frontmatter is read from it, and an HTML answer doesn't count.
+	 */
+	public function test_markdown_versions(): void {
+		$markdown = array( 'content-type' => 'text/markdown; charset=utf-8' );
+		$results  = $this->results(
+			array(
+				'markdown /' => array( 200, "---\ntitle: Store\n---\n# Store", $markdown ),
+				'/'          => array( 200, '<html><head><link rel="alternate" type="text/markdown" href="/index.md"></head></html>' ),
+			)
+		);
+		$this->assertTrue( $results['markdown-pages'] );
+		$this->assertTrue( $results['markdown-alternate-link'] );
+		$this->assertTrue( $results['markdown-frontmatter'] );
+		$this->assertArrayNotHasKey( 'markdown-twin', $results );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results(
+			array(
+				'markdown /' => array( 200, '# Store', array( 'content-type' => 'text/plain' ) ),
+				'/'          => array( 200, '<html><head></head></html>' ),
+			)
+		);
+		$this->assertTrue( $results['markdown-pages'], 'Plain text Markdown counts' );
+		$this->assertFalse( $results['markdown-alternate-link'] );
+		$this->assertFalse( $results['markdown-frontmatter'], 'Markdown without frontmatter' );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( 'markdown /' => array( 200, '<html></html>', array( 'content-type' => 'text/html' ) ) ) );
+		$this->assertFalse( $results['markdown-pages'], 'An HTML answer to Accept: text/markdown' );
+		$this->assertFalse( $results['markdown-frontmatter'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( 'markdown /' => array( 503, '' ) ) );
+		$this->assertNull( $results['markdown-pages'] );
+		$this->assertNull( $results['markdown-frontmatter'] );
+	}
+
+	/**
+	 * @testdox The feed and home page structured data are read from what the store serves.
+	 */
+	public function test_feed_and_home_page(): void {
+		$results = $this->results(
+			array(
+				'/feed/' => array( 200, '<?xml version="1.0"?><rss version="2.0"></rss>' ),
+				'/'      => array( 200, '<html><head><script type="application/ld+json">{"@type":"WebSite","potentialAction":{"@type":"SearchAction"}}</script></head></html>' ),
+			)
+		);
+
+		$this->assertTrue( $results['feed-present'] );
+		$this->assertTrue( $results['site-search-action'] );
+		$this->assertFalse( $results['sameas-links'] );
+	}
+
+	/**
+	 * @testdox A failing feed is not checked, and only a SearchAction counts as on-site search.
+	 */
+	public function test_failing_feed_and_other_actions(): void {
+		$results = $this->results(
+			array(
+				'/feed/' => array( 503, '' ),
+				'/'      => array( 200, '<html><script type="application/ld+json">{"@type":"WebSite","potentialAction":{"@type":"ReadAction"}}</script></html>' ),
+			)
+		);
+		$this->assertNull( $results['feed-present'] );
+		$this->assertFalse( $results['site-search-action'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results(
+			array(
+				'/' => array( 200, '<html><script type="application/ld+json">{"@type":"WebSite","potentialAction":[{"@type":"ReadAction"},{"@type":"SearchAction"}]}</script></html>' ),
+			)
+		);
+		$this->assertTrue( $results['site-search-action'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results(
+			array(
+				'/' => array( 200, '<html><script type="application/ld+json">{"@type":"WebSite","potentialAction":{"@type":"https://schema.org/SearchAction"}}</script></html>' ),
+			)
+		);
+		$this->assertTrue( $results['site-search-action'], 'A type written as a URL' );
+	}
+
+	/**
+	 * @testdox Code blocks with a language class, an author or publish date, and a search box with autocomplete are found on the home page.
+	 */
+	public function test_page_markup_items(): void {
+		$home    = '<html><head><meta property="article:published_time" content="2026-11-01"></head><body>'
+			. '<pre><code class="language-php">echo 1;</code></pre>'
+			. '<form><input type="search" name="s" role="combobox" aria-autocomplete="list"></form></body></html>';
+		$results = $this->results( array( '/' => array( 200, $home ) ) );
+
+		$this->assertTrue( $results['code-block-language'] );
+		$this->assertTrue( $results['editorial-metadata'] );
+		$this->assertTrue( $results['site-search-autocomplete'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$plain   = '<html><body><pre><code>echo 1;</code></pre><form><input type="search" name="s"></form></body></html>';
+		$results = $this->results( array( '/' => array( 200, $plain ) ) );
+		$this->assertFalse( $results['code-block-language'], 'A code block without a language' );
+		$this->assertFalse( $results['editorial-metadata'] );
+		$this->assertFalse( $results['site-search-autocomplete'], 'A search box without autocomplete' );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( '/' => array( 200, '<html><script type="application/ld+json">{"@type":"WebPage","datePublished":"2026-11-01"}</script></html>' ) ) );
+		$this->assertTrue( $results['editorial-metadata'], 'A publish date in JSON-LD' );
+	}
+
+	/**
+	 * @testdox An MCP server card counts at either well-known path when it names the server and where to connect.
+	 */
+	public function test_mcp_server_card(): void {
+		$results = $this->results( array( '/.well-known/mcp.json' => array( 200, '{"name":"Shop","endpoint":"https://example.org/mcp"}' ) ) );
+		$this->assertTrue( $results['mcp-server-card'] );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( '/.well-known/mcp/server-card.json' => array( 200, '{"serverInfo":{"name":"Shop"}}' ) ) );
+		$this->assertFalse( $results['mcp-server-card'], 'A card that does not say where to connect' );
+
+		remove_all_filters( 'pre_http_request' );
+		$results = $this->results( array( '/.well-known/mcp/server-card.json' => array( 503, '' ) ) );
+		$this->assertNull( $results['mcp-server-card'], 'A server error is not checked' );
+	}
+
+	/**
+	 * @testdox DNS-AID and an automated product feed are listed as not checked yet.
+	 */
+	public function test_not_read_yet(): void {
+		$results = $this->results( array() );
+
+		$this->assertArrayHasKey( 'dns-aid', $results );
+		$this->assertNull( $results['dns-aid'] );
+		$this->assertNull( $results['product-feed-automation'] );
+	}
+
+	/**
+	 * @testdox One scan requests the pages the store checks and the list share only once.
+	 */
+	public function test_scan_shares_responses(): void {
+		$requests = array();
+		update_option( 'woocommerce_coming_soon', 'no' );
+		add_filter(
+			'pre_http_request',
+			static function ( $pre, $args, $url ) use ( &$requests ) {
+				unset( $pre );
+				// The Markdown request is a different request to a URL already loaded.
+				if ( ! isset( $args['headers']['Accept'] ) ) {
+					$requests[] = $url;
+				}
+				return array(
+					'headers'  => array(),
+					'body'     => '',
+					'response' => array(
+						'code'    => 404,
+						'message' => '',
+					),
+					'cookies'  => array(),
+				);
+			},
+			10,
+			3
+		);
+
+		wc_get_container()->get( AgentReadiness::class )->get_report();
+
+		$this->assertSame( array_values( array_unique( $requests ) ), $requests );
+	}
+
+	/**
+	 * @testdox In coming soon mode nothing is requested and every item is not checked.
+	 */
+	public function test_coming_soon(): void {
+		update_option( 'woocommerce_coming_soon', 'yes' );
+		add_filter(
+			'pre_http_request',
+			function () {
+				$this->fail( 'No request while in coming soon mode' );
+			}
+		);
+
+		foreach ( $this->scan() as $item ) {
+			$this->assertNull( $item['found'], $item['id'] );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductFailuresTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductFailuresTest.php
new file mode 100644
index 00000000000..05f66fdcc80
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductFailuresTest.php
@@ -0,0 +1,174 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFailures;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for what each product fails, stored by the scan.
+ */
+class ProductFailuresTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Readiness service.
+	 *
+	 * @var AgentReadiness
+	 */
+	private AgentReadiness $readiness;
+
+	/**
+	 * Run before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		StoreOutputChecksTest::block_requests();
+		$this->readiness = wc_get_container()->get( AgentReadiness::class );
+	}
+
+	/**
+	 * Run after each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'pre_http_request' );
+		delete_option( AgentReadiness::REPORT_OPTION );
+		parent::tearDown();
+	}
+
+	/**
+	 * A count from the stored report.
+	 *
+	 * @param string $id Check id.
+	 */
+	private function check_count( string $id ): int {
+		return (int) array_column( (array) $this->readiness->get_cached_report()['checks'], 'count', 'id' )[ $id ];
+	}
+
+	/**
+	 * @testdox A scan stores what each product fails and clears products it no longer tests.
+	 */
+	public function test_scan_stores_and_clears(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->readiness->refresh_report();
+
+		$stored = ProductFailures::for_product( $product->get_id() );
+		$this->assertContains( 'missing_images', $stored );
+		$this->assertSame( 1, $this->check_count( 'missing_images' ) );
+		$this->assertNotEmpty( array_filter( $stored, static fn( string $value ): bool => 0 === strpos( $value, ProductScan::NOT_LISTABLE . ':' ) ) );
+
+		wp_update_post(
+			array(
+				'ID'          => $product->get_id(),
+				'post_status' => 'draft',
+			)
+		);
+		$this->readiness->refresh_report();
+		$this->assertSame( array(), ProductFailures::for_product( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox The report's counts follow the rows, and a report from before rows were stored keeps its own.
+	 */
+	public function test_counts_follow_rows(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+
+		ProductFailures::store( $product->get_id(), array() );
+		$this->assertSame( 0, $this->check_count( 'missing_images' ) );
+		$channels = (array) $this->readiness->get_cached_report()['channels'];
+		$this->assertSame( 1, reset( $channels )['complete'] );
+
+		update_option(
+			AgentReadiness::REPORT_OPTION,
+			array(
+				'checks' => array(
+					array(
+						'id'    => 'missing_images',
+						'count' => 3,
+					),
+				),
+			)
+		);
+		$this->assertSame( 3, $this->check_count( 'missing_images' ) );
+	}
+
+	/**
+	 * @testdox A product that stores rows while the scan runs keeps them.
+	 */
+	public function test_product_stored_during_scan_keeps_its_rows(): void {
+		$during_id = 0;
+		$hook      = static function () use ( &$during_id ) {
+			if ( 0 !== $during_id ) {
+				return;
+			}
+			// This batch is already loaded, so the new product is not one of its results.
+			$during_id = -1;
+			$during_id = WC_Helper_Product::create_simple_product()->get_id();
+			add_post_meta( $during_id, ProductFailures::META_KEY, 'missing_images' );
+		};
+		add_action( 'woocommerce_agent_readiness_scan_batch', $hook );
+
+		try {
+			$this->readiness->refresh_report();
+		} finally {
+			remove_action( 'woocommerce_agent_readiness_scan_batch', $hook );
+		}
+
+		$this->assertGreaterThan( 0, $during_id );
+		$this->assertContains( 'missing_images', ProductFailures::for_product( $during_id ) );
+	}
+
+	/**
+	 * @testdox Two rows for one value on one product count as one product.
+	 */
+	public function test_duplicate_rows_count_once(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		add_post_meta( $product->get_id(), ProductFailures::META_KEY, ProductFailures::CHECKED );
+		add_post_meta( $product->get_id(), ProductFailures::META_KEY, ProductFailures::CHECKED );
+
+		$this->assertSame( 1, ProductFailures::counts()[ ProductFailures::CHECKED ] );
+	}
+
+	/**
+	 * @testdox The counts are read per site, so after switch_to_blog() they are the other site's.
+	 */
+	public function test_counts_per_site(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Requires multisite.' );
+		}
+		$other = self::factory()->blog->create();
+		ProductFailures::store( WC_Helper_Product::create_simple_product()->get_id(), array() );
+		$this->assertSame( 1, ProductFailures::counts()[ ProductFailures::CHECKED ] ?? 0 );
+
+		switch_to_blog( $other );
+		$on_other = ProductFailures::counts();
+		restore_current_blog();
+
+		$this->assertArrayNotHasKey( ProductFailures::CHECKED, $on_other );
+		$this->assertSame( 1, ProductFailures::counts()[ ProductFailures::CHECKED ] ?? 0, 'Back on the first site' );
+	}
+
+	/**
+	 * @testdox Every checked product has a checked row, passing or not, which for_product leaves out and the product total counts.
+	 */
+	public function test_checked_row_counts_products(): void {
+		$failing = WC_Helper_Product::create_simple_product();
+		$passing = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+
+		ProductFailures::store( $passing->get_id(), array() );
+		$this->assertSame( array(), ProductFailures::for_product( $passing->get_id() ) );
+		$this->assertContains( ProductFailures::CHECKED, (array) get_post_meta( $passing->get_id(), ProductFailures::META_KEY, false ) );
+		$this->assertNotContains( ProductFailures::CHECKED, ProductFailures::for_product( $failing->get_id() ) );
+		$this->assertSame( 2, ProductFailures::counts()[ ProductFailures::CHECKED ] );
+
+		ProductFailures::forget( $passing->get_id() );
+		$this->assertSame( 1, ProductFailures::counts()[ ProductFailures::CHECKED ], 'A write clears the counts read earlier in the request' );
+		$this->assertSame( 1, $this->readiness->get_cached_report()['products'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductsListTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductsListTest.php
new file mode 100644
index 00000000000..fb28c331831
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ProductsListTest.php
@@ -0,0 +1,370 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings;
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFailures;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductsList;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for stored failures per product and the Products list.
+ */
+class ProductsListTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Readiness service.
+	 *
+	 * @var AgentReadiness
+	 */
+	private AgentReadiness $readiness;
+
+	/**
+	 * System under test.
+	 *
+	 * @var ProductsList
+	 */
+	private ProductsList $sut;
+
+	/**
+	 * Run before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		StoreOutputChecksTest::block_requests();
+		update_option( 'woocommerce_feature_' . AgenticCommerceSettings::FEATURE_ID . '_enabled', 'yes' );
+		$this->readiness = wc_get_container()->get( AgentReadiness::class );
+		$this->sut       = wc_get_container()->get( ProductsList::class );
+	}
+
+	/**
+	 * Run after each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'pre_http_request' );
+		delete_option( AgentReadiness::REPORT_OPTION );
+		delete_option( 'woocommerce_feature_' . AgenticCommerceSettings::FEATURE_ID . '_enabled' );
+		unset( $_GET[ ProductsList::FILTER_PARAM ] );
+		parent::tearDown();
+	}
+
+	/**
+	 * A count from the stored report.
+	 *
+	 * @param string $id Check id.
+	 */
+	private function check_count( string $id ): int {
+		return (int) array_column( (array) $this->readiness->get_cached_report()['checks'], 'count', 'id' )[ $id ];
+	}
+
+	/**
+	 * @testdox Reading the report costs a few queries, however many example products the checks list.
+	 */
+	public function test_report_read_is_bounded(): void {
+		global $wpdb;
+		for ( $i = 0; $i < 6; $i++ ) {
+			WC_Helper_Product::create_simple_product();
+		}
+		$this->readiness->refresh_report();
+		wp_cache_flush();
+
+		$before = $wpdb->num_queries;
+		$report = $this->readiness->get_cached_report();
+
+		$this->assertCount( 6, array_column( (array) $report['checks'], 'product_ids', 'id' )['missing_images'], 'Six example products' );
+		$this->assertLessThanOrEqual( 4, $wpdb->num_queries - $before, 'The report option, the counts and one meta read' );
+	}
+
+	/**
+	 * @testdox Saving a product updates its rows and the report's counts, without another scan.
+	 */
+	public function test_save_updates_rows_and_counts(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+
+		$product->set_image_id( self::factory()->attachment->create() );
+		$product->save();
+
+		$this->assertNotContains( 'missing_images', ProductFailures::for_product( $product->get_id() ) );
+		$this->assertSame( 0, $this->check_count( 'missing_images' ) );
+		$this->assertSame( array(), (array) array_column( (array) $this->readiness->get_cached_report()['checks'], 'product_ids', 'id' )['missing_images'] );
+
+		$product->set_status( 'draft' );
+		$product->save();
+		$this->assertSame( array(), ProductFailures::for_product( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox The product total and complete count follow saves: a new product counts, and one moved to draft no longer does.
+	 */
+	public function test_save_updates_product_total(): void {
+		WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+		$this->assertSame( 1, $this->readiness->get_cached_report()['products'] );
+
+		$added = WC_Helper_Product::create_simple_product();
+		$added->set_image_id( self::factory()->attachment->create() );
+		$added->save();
+		$report = $this->readiness->get_cached_report();
+		$this->assertSame( 2, $report['products'] );
+		foreach ( (array) $report['channels'] as $id => $channel ) {
+			$this->assertSame( 2, $channel['listable'] + $channel['not_listable'], (string) $id );
+			$this->assertLessThanOrEqual( 2, $channel['complete'], (string) $id );
+		}
+
+		$added->set_status( 'draft' );
+		$added->save();
+		$this->assertSame( 1, $this->readiness->get_cached_report()['products'] );
+	}
+
+	/**
+	 * @testdox A save that changes only stock doesn't test the product again.
+	 */
+	public function test_stock_save_is_skipped(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+		delete_post_meta( $product->get_id(), ProductFailures::META_KEY );
+
+		$product->set_stock_quantity( 7 );
+		$product->save();
+
+		$this->assertSame( array(), ProductFailures::for_product( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox A save that changes only the Google brand or MPN tests the product again.
+	 */
+	public function test_brand_or_mpn_save_is_rechecked(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+		delete_post_meta( $product->get_id(), ProductFailures::META_KEY );
+
+		$product->update_meta_data( '_wc_gla_brand', 'Acme' );
+		$product->save();
+
+		$this->assertContains( 'missing_images', ProductFailures::for_product( $product->get_id() ) );
+
+		delete_post_meta( $product->get_id(), ProductFailures::META_KEY );
+		$product->set_stock_quantity( 4 );
+		$product->update_meta_data( '_wc_gla_mpn', 'MPN-1' );
+		$product->save();
+
+		$this->assertContains( 'missing_images', ProductFailures::for_product( $product->get_id() ), 'Stock plus MPN is not a stock-only save' );
+	}
+
+	/**
+	 * @testdox A save that changes nothing doesn't test the product again.
+	 */
+	public function test_unchanged_save_is_skipped(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+		delete_post_meta( $product->get_id(), ProductFailures::META_KEY );
+
+		$product->save();
+
+		$this->assertSame( array(), ProductFailures::stored( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Trashing a product removes its rows from the counts; restoring it tests it again.
+	 */
+	public function test_trash_and_restore(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$this->readiness->refresh_report();
+		$this->assertSame( 1, $this->check_count( 'missing_images' ) );
+
+		wp_trash_post( $product->get_id() );
+		$this->assertSame( array(), ProductFailures::stored( $product->get_id() ) );
+		$this->assertSame( 0, $this->check_count( 'missing_images' ) );
+
+		wp_untrash_post( $product->get_id() );
+		wp_publish_post( $product->get_id() );
+		$this->assertContains( 'missing_images', ProductFailures::for_product( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox Saving a variation updates its parent.
+	 */
+	public function test_variation_save_updates_parent(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$this->readiness->refresh_report();
+		delete_post_meta( $parent->get_id(), ProductFailures::META_KEY );
+
+		$variation = wc_get_product( $parent->get_children()[0] );
+		$variation->set_description( 'Changed' );
+		$variation->save();
+
+		$this->assertContains( 'missing_images', ProductFailures::for_product( $parent->get_id() ) );
+	}
+
+	/**
+	 * @testdox Trashing or deleting the variations that lack a GTIN tests the parent again, though removing a variation doesn't save it.
+	 */
+	public function test_removed_variation_updates_parent(): void {
+		// A parent still noted after the shutdown callback was removed, as the test case does between tests.
+		$earlier = WC_Helper_Product::create_variation_product();
+		wc_get_product( $earlier->get_children()[0] )->delete( true );
+		remove_all_actions( 'shutdown' );
+
+		$trashed = WC_Helper_Product::create_variation_product();
+		$deleted = WC_Helper_Product::create_variation_product();
+		foreach ( array( $trashed, $deleted ) as $parent ) {
+			update_post_meta( $parent->get_children()[0], '_global_unique_id', $parent === $trashed ? '4006381333931' : '9780306406157' );
+		}
+		$this->readiness->refresh_report();
+		$this->assertContains( 'missing_gtin', ProductFailures::for_product( $trashed->get_id() ) );
+		$this->assertContains( 'missing_gtin', ProductFailures::for_product( $deleted->get_id() ) );
+
+		foreach ( array_slice( $trashed->get_children(), 1 ) as $id ) {
+			wc_get_product( $id )->delete( false );
+		}
+		foreach ( array_slice( $deleted->get_children(), 1 ) as $id ) {
+			wc_get_product( $id )->delete( true );
+		}
+		$this->assertContains( 'missing_gtin', ProductFailures::for_product( $trashed->get_id() ), 'Tested at the end of the request, not per variation' );
+
+		$this->run_retest();
+
+		$this->assertNotContains( 'missing_gtin', ProductFailures::for_product( $trashed->get_id() ), 'Trashed' );
+		$this->assertNotContains( 'missing_gtin', ProductFailures::for_product( $deleted->get_id() ), 'Deleted' );
+	}
+
+	/**
+	 * @testdox Deleting a variable product leaves no rows behind, though deleting its variations notes it for a test.
+	 */
+	public function test_deleted_variable_product_leaves_no_rows(): void {
+		$parent = WC_Helper_Product::create_variation_product();
+		$this->readiness->refresh_report();
+		$parent_id = $parent->get_id();
+		$this->assertNotSame( array(), ProductFailures::for_product( $parent_id ) );
+
+		$parent->delete( true );
+		$this->run_retest();
+
+		$this->assertSame( array(), ProductFailures::stored( $parent_id ) );
+	}
+
+	/**
+	 * @testdox The readiness condition is joined to the list's own meta query with AND, so an OR in it doesn't widen the filter.
+	 */
+	public function test_filter_keeps_or_meta_query_apart(): void {
+		global $pagenow, $wp_the_query;
+		$previous = array( $pagenow, $wp_the_query );
+		$failing  = WC_Helper_Product::create_simple_product();
+		$other    = WC_Helper_Product::create_simple_product();
+		ProductFailures::store( $failing->get_id(), array( 'missing_images' => array() ) );
+		ProductFailures::store( $other->get_id(), array() );
+		foreach ( array( $failing, $other ) as $product ) {
+			update_post_meta( $product->get_id(), '_test_flag', 'yes' );
+		}
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Only is_admin() reads it here.
+		$GLOBALS['current_screen']          = new class() {
+			/**
+			 * Whether this is an admin screen.
+			 */
+			public function in_admin(): bool {
+				return true;
+			}
+		};
+		$pagenow                            = 'edit.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_GET[ ProductsList::FILTER_PARAM ] = 'missing_images';
+
+		try {
+			$query = new \WP_Query();
+			$query->set( 'post_type', 'product' );
+			$query->set( 'fields', 'ids' );
+			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The list's own query in this test.
+			$query->set(
+				'meta_query',
+				array(
+					'relation' => 'OR',
+					array(
+						'key'   => '_test_flag',
+						'value' => 'yes',
+					),
+					array(
+						'key'   => '_test_other',
+						'value' => 'yes',
+					),
+				)
+			);
+			$wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			$this->sut->apply_filter( $query );
+
+			$this->assertSame( array( $failing->get_id() ), array_map( 'intval', $query->get_posts() ) );
+		} finally {
+			list( $pagenow, $wp_the_query ) = $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			unset( $GLOBALS['current_screen'], $_GET[ ProductsList::FILTER_PARAM ] );
+		}
+	}
+
+	/**
+	 * Run the end-of-request test the hooks queued, as the shutdown action would.
+	 */
+	private function run_retest(): void {
+		global $wp_filter;
+		foreach ( $wp_filter['shutdown']->callbacks ?? array() as $callbacks ) {
+			foreach ( $callbacks as $callback ) {
+				if ( is_array( $callback['function'] ) && $callback['function'][0] instanceof ProductsList ) {
+					call_user_func( $callback['function'] );
+				}
+			}
+		}
+	}
+
+	/**
+	 * @testdox Saves store nothing before a scan.
+	 */
+	public function test_before_a_scan(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_description( 'Changed' );
+		$product->save();
+		$this->assertSame( array(), ProductFailures::for_product( $product->get_id() ) );
+	}
+
+	/**
+	 * @testdox The filter limits the main Products list query, and only there.
+	 */
+	public function test_filter_query(): void {
+		global $pagenow, $wp_the_query;
+		$previous = array( $pagenow, $wp_the_query );
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Only is_admin() reads it here.
+		$GLOBALS['current_screen']          = new class() {
+			/**
+			 * Whether this is an admin screen.
+			 */
+			public function in_admin(): bool {
+				return true;
+			}
+		};
+		$pagenow                            = 'edit.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_GET[ ProductsList::FILTER_PARAM ] = 'missing_images';
+
+		try {
+			$query = new \WP_Query();
+			$query->set( 'post_type', 'product' );
+			$wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			$this->sut->apply_filter( $query );
+			$this->assertSame( 'missing_images', $query->get( 'meta_query' )[0]['value'] );
+
+			$other = new \WP_Query();
+			$other->set( 'post_type', 'product' );
+			$this->sut->apply_filter( $other );
+			$this->assertSame( '', $other->get( 'meta_query' ) );
+
+			update_option( 'woocommerce_feature_' . AgenticCommerceSettings::FEATURE_ID . '_enabled', 'no' );
+			$off = new \WP_Query();
+			$off->set( 'post_type', 'product' );
+			$wp_the_query = $off; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			$this->sut->apply_filter( $off );
+			$this->assertSame( '', $off->get( 'meta_query' ), 'The filter stays off with the feature flag' );
+		} finally {
+			// A failed assertion must not leave the next test on an admin products screen.
+			list( $pagenow, $wp_the_query ) = $previous; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			unset( $GLOBALS['current_screen'], $_GET[ ProductsList::FILTER_PARAM ] );
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
index de890effb9d..cb6afdc18af 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
@@ -119,7 +119,7 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
 		$output_checks = new StoreOutputChecks();
 		$output_checks->init( new ComingSoonHelper() );
 		$agent_readiness = new AgentReadiness();
-		$agent_readiness->init( $store_conditions, $output_checks, $this->sut );
+		$agent_readiness->init( $store_conditions, $output_checks, $this->sut, wc_get_container()->get( \Automattic\WooCommerce\Internal\AgentReadiness\DeclaredToAgents::class ) );
 		return $agent_readiness;
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
index fdf9f9d55a5..dd25dcce01b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
@@ -89,6 +89,26 @@ class StoreResponsesTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( home_url( '/missing' ), home_url( '/timeout' ), home_url( '/' ) ), $this->requested );
 	}

+	/**
+	 * @testdox A failed Markdown request to the home page doesn't stop the scan when the page itself loads.
+	 */
+	public function test_failed_markdown_request_keeps_the_scan_going(): void {
+		add_filter(
+			'pre_http_request',
+			static function ( $pre, $args ) {
+				unset( $pre );
+				return 'text/markdown' === ( $args['headers']['Accept'] ?? '' ) ? new \WP_Error( 'http_request_failed', 'Timed out' ) : self::response( 200, '<html></html>' );
+			},
+			10,
+			2
+		);
+		$sut = new StoreResponses( true );
+
+		$this->assertNull( $sut->get_as_markdown( '/' ) );
+		$this->assertSame( 200, $sut->home_page()['status'] );
+		$this->assertSame( 200, $sut->get( '/other' )['status'] );
+	}
+
 	/**
 	 * @testdox Redirects on the store's host are followed; a redirect to another host is not requested.
 	 */