# woocommerce-pos/1.10.2/includes/Sync/Config_Fingerprint.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.2. 311 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.2/code/includes/Sync/Config_Fingerprint.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.2/raw/includes/Sync/Config_Fingerprint.php
- Modified: 2026-08-25T20:38:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.2/code/includes/Sync/Config_Fingerprint.php#L10-L20`.

```php
<?php
/**
 * WCPOS sync read surface.
 *
 * @package WCPOS\WooCommercePOS\Sync
 */

namespace WCPOS\WooCommercePOS\Sync;

use WCPOS\WooCommercePOS\Services\Barcode_Field;

// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.

/**
 * Representation-config FINGERPRINT — the fourth change signal ADR 0006 adds to
 * close Scenario 1 (settings-change staleness), the gap ADR 0005's three-tier
 * hybrid is STRUCTURALLY blind to.
 *
 * THE PROBLEM. A global WCPOS/WooCommerce SETTING change alters the *served
 * representation* of MANY records without bumping any record's `date_modified`
 * and without touching any record's storage. The canonical case: an admin flips
 * which meta key is the POS "barcode" field (`_sku` -> `_global_unique_id`).
 * Every product's effective barcode changes, but no product row changes — so
 * TIER 1 sequence-log (no save hook fires), TIER 2 hash-checksum (raw row
 * unchanged) are both blind, and TIER 3 revision-hash would catch it but is
 * never polled (112-142s at 10k). See
 * docs/experiments/config-change-signal-2026-06-17.md.
 *
 * THE SIGNAL. Per collection, a cheap FINGERPRINT = md5 over the canonicalized
 * (sorted-key) set of representation-affecting settings for that collection. The
 * client retains a per-collection baseline and diffs each poll; on a move it
 * marks that collection stale and re-derives (or re-fetches).
 *
 * WHY HASHED FROM LIVE OPTIONS, NOT A HOOK COUNTER. The endpoint recomputes this
 * from the ACTUAL current options on every call, so it is SELF-HEALING: a setting
 * changed by a hook-bypassing path (another plugin's `update_option`, wp-cli, a
 * direct SQL write to `wp_options`) is detected on the next poll just like a
 * hooked one — the property a hook-only counter could not give. Live recompute
 * is the ONLY mechanism; there is no stored snapshot to go stale.
 *
 * ADR 0003 (discovery only): the fingerprint LOCATES that a representation config
 * moved; it never produces a value the POS trusts. The trusted re-derivation runs
 * client-side over already-synced filtered payloads (or a normal filtered
 * re-fetch). `barcode_fields` reports the resolved active PAYLOAD field names so
 * the client can rebuild its local barcode index without a server round-trip.
 *
 * The representation-setting set is a deliberate SUPERSET of every option that
 * changes the served representation: under-scoping would MISS a config change, so
 * the safe direction is to over-include. The set MUST GROW as new
 * representation-affecting settings are added.
 */
final class Config_Fingerprint {
	/**
	 * Bump when a new one-time cleanup step is added to maybe_cleanup_legacy_options().
	 * Stored per-site so the sweep runs exactly once per upgrade, not once per request.
	 */
	public const CLEANUP_VERSION = 1;

	/**
	 * The latch for the one-time sweep. DELIBERATELY NOT under the
	 * LEGACY_PROACTIVE_OPTION_PREFIX namespace: an option that both names the sweep
	 * and lives inside the range the sweep deletes would erase its own latch and
	 * re-run forever.
	 */
	public const CLEANUP_VERSION_OPTION = 'woocommerce_pos_sync_config_fingerprint_cleanup_version';

	/**
	 * Where the removed PROACTIVE snapshot used to be stored, per collection. The
	 * write half is gone (its only reader was deleted first); the rows it left in
	 * `wp_options` on existing installs are swept by maybe_cleanup_legacy_options().
	 */
	private const LEGACY_PROACTIVE_OPTION_PREFIX = 'woocommerce_pos_sync_config_fp_';

	/**
	 * Raw barcode META key -> synced-doc PAYLOAD field name, for the mappings the
	 * catalog proxy serves as NATIVE top-level wc/v3 fields. Keys are the values
	 * the production barcode setting can hold; values are the top-level payload
	 * field names the client indexes against.
	 *
	 * HONESTY CONSTRAINT (review finding 9): this map may ONLY list a TOP-LEVEL
	 * field the sync read surface ACTUALLY serves. That surface is the catalog
	 * proxy → raw wc/v3 (Catalog_Proxy_Controller forwards to /wc/v3/products),
	 * which emits `sku` and `global_unique_id` natively but NEVER a top-level
	 * `barcode` — that stamping lives only on the wcpos/v1 Products_Controller
	 * (an override of a DIFFERENT namespace the proxy never dispatches through).
	 *
	 * A custom meta key has no top-level field, but its value DOES reach the
	 * client: proxied responses carry it in `meta_data` (pinned by
	 * Test_Catalog_Proxy_Barcode read-parity tests). So a key absent from this
	 * map is advertised as a `meta_data:<key>` SELECTOR by barcode_fields() if
	 * WooCommerce does not classify it as internal. Internal product properties
	 * are excluded from serialized `meta_data`, so their selector list remains
	 * empty.
	 */
	private const BARCODE_META_TO_PAYLOAD = array(
		'_sku'              => 'sku',
		'_global_unique_id' => 'global_unique_id',
	);

	/** The collections this signal covers, in the engine's vocabulary. */
	/**
	 * Registry projection (#421 increment 8): the fingerprinted collections.
	 */
	public static function collections(): array {
		return array_keys( Collections::with( 'fingerprint' ) );
	}

	/**
	 * The PAYLOAD CONTRACT version per collection — bump when the SHAPE of a served record changes.
	 *
	 * # Why this is a representation setting
	 *
	 * ADR 0006 built this signal for "a global setting change alters the served representation of
	 * MANY records without bumping any record's `date_modified`". A PLUGIN UPGRADE that changes a
	 * payload's shape is the same event, and the three tiers are blind to it in exactly the same
	 * way: tier 1 writes no journal row (no save hook fires), tier 2's digest is derived from the
	 * raw DB row and does not move, and tier 3 is only ever reached from a tier-2 mismatch. Without
	 * a signal here, a client that synced a record under the old shape keeps it INDEFINITELY.
	 *
	 * 1.10.0 shipped variations serialized through the PRODUCTS controller — an `images` array
	 * instead of the singular `image`, and `get_name()` (the generated post title, which
	 * `generate_product_title()` collapses to just the parent name at 3+ attributes) instead of
	 * `wc_get_formatted_variation()`. A client can be taught to read either image shape, but a
	 * collapsed name is indistinguishable from a correct one, so tolerance cannot repair it. Only a
	 * re-pull can, and only this signal asks for one.
	 *
	 * # Why a version rather than a hash of the payload
	 *
	 * The shape is a property of the CODE, not of the store's data or settings, so there is nothing
	 * live to recompute it from — the honest form is a constant a human bumps in the same commit
	 * that changes the shape. Deliberately NOT an option or a filter: nobody but us can change what
	 * we serve (see the constants-not-env-vars rule in the repo's agent context).
	 *
	 * # Safety against an un-upgraded store
	 *
	 * A store still on the old plugin never moves this value, so its clients see no change and
	 * re-fetch nothing. That is what lets the migration ship with no version gate and no bespoke
	 * purge lane: the failure mode a gate would defend against is structurally absent.
	 *
	 * @var array<string, int>
	 */
	/**
	 * The contract version every collection starts at, and the value that must NOT appear in a
	 * fingerprint — see representation_settings().
	 *
	 * @var int
	 */
	private const BASELINE_CONTRACT_VERSION = 1;

	private const PAYLOAD_CONTRACT_VERSION = array(
		// 2 (1.10.1): variations are serialized through WC_REST_Product_Variations_Controller
		// instead of the products controller — singular `image`, `wc_get_formatted_variation()`
		// `name`, and no product-only fields. See the 1.10.1 variations spec, S1/S6.
		'variations' => 2,
		'products'   => 1,
		'tax_rates'  => 1,
	);

	/**
	 * The canonicalized representation-affecting settings for a collection.
	 * DELIBERATELY A SUPERSET: this set must GROW as new representation settings
	 * are added, because an omitted setting would silently miss its config
	 * change. ksort() is mandatory (ADR 0006 "Canonicalization discipline") — an
	 * unstable serialization order would change the hash every poll and
	 * false-positive forever.
	 */
	public function representation_settings( string $collection ): array {
		if ( \in_array( $collection, self::barcode_collections(), true ) ) {
			$settings = array( 'barcode_field' => Barcode_Field::meta_key() );
		} else {
			// tax_rates (and any non-barcode collection): no representation
			// setting tracked yet. Still hashed, so adding one later just widens
			// this array.
			$settings = array();
		}

		/*
		 * The served SHAPE is as much a part of the representation as the settings that fill it —
		 * see PAYLOAD_CONTRACT_VERSION.
		 *
		 * Added ONLY above the baseline. Adding it unconditionally would change the serialization
		 * of every collection still at version 1 — `{"barcode_field":"_sku"}` becomes
		 * `{"barcode_field":"_sku","payload_contract":1}`, and tax_rates' `[]` becomes an object —
		 * so their fingerprints would move too. A fingerprint move marks the collection stale, so
		 * upgrading would trigger a full PRODUCTS catalogue re-fetch and a tax-rate refresh on every
		 * active till, for a shape that did not change. Omitting the key at the baseline keeps
		 * version-1 serialization byte-identical, so only a collection whose contract actually moved
		 * is re-pulled. A future bump anywhere makes the key appear, which is itself the change.
		 */
		$contract = self::payload_contract_version( $collection );
		if ( self::BASELINE_CONTRACT_VERSION < $contract ) {
			$settings['payload_contract'] = $contract;
		}

		ksort( $settings );

		return $settings;
	}

	/**
	 * This collection's payload contract version. Unknown collections report 1 rather than 0, so a
	 * collection added to the registry without a deliberate entry starts from the same baseline as
	 * every other unbumped one instead of silently reading as "older than everything".
	 *
	 * @param string $collection Collection name.
	 */
	public static function payload_contract_version( string $collection ): int {
		return self::PAYLOAD_CONTRACT_VERSION[ $collection ] ?? self::BASELINE_CONTRACT_VERSION;
	}

	/**
	 * The per-collection fingerprint — md5 over the canonical settings JSON.
	 */
	public function fingerprint( string $collection ): string {
		return md5( (string) wp_json_encode( $this->representation_settings( $collection ) ) );
	}

	/**
	 * The resolved active barcode selectors for a collection: the payload field
	 * name the client indexes (`sku`, `global_unique_id`) for native mappings,
	 * or a `meta_data:<key>` selector for any other configured meta key (#1385
	 * — the proxied `meta_data` carries the value, so the client derives its
	 * local index from the named entry). Empty for collections with no barcode
	 * mapping.
	 */
	public function barcode_fields( string $collection ): array {
		if ( ! \in_array( $collection, self::barcode_collections(), true ) ) {
			return array();
		}
		$meta_key = Barcode_Field::meta_key();

		if ( ! \array_key_exists( $meta_key, self::BARCODE_META_TO_PAYLOAD ) ) {
			// @phpstan-ignore-next-line -- WC_Data_Store forwards this public method to its loaded store.
			$internal_meta_keys = \WC_Data_Store::load( 'product' )->get_internal_meta_keys();

			return \in_array( $meta_key, $internal_meta_keys, true ) ? array() : array( 'meta_data:' . $meta_key );
		}
		$payload_field = self::BARCODE_META_TO_PAYLOAD[ $meta_key ];

		// wc/v3 only serves global_unique_id from WC 9.2 — advertising it on older
		// versions would tell the client to index a field that never arrives.
		if ( 'global_unique_id' === $payload_field && \function_exists( 'WC' ) && version_compare( WC()->version, '9.2', '<' ) ) {
			return array();
		}

		return array( $payload_field );
	}

	/**
	 * The endpoint's read model: fingerprints + barcode fields, per collection.
	 */
	public function snapshot( array $collections ): array {
		$fingerprints   = array();
		$barcode_fields = array();
		foreach ( $collections as $collection ) {
			$collection                  = (string) $collection;
			$fingerprints[ $collection ]   = $this->fingerprint( $collection );
			$barcode_fields[ $collection ] = $this->barcode_fields( $collection );
		}

		return array(
			'fingerprints'   => $fingerprints,
			'barcode_fields' => $barcode_fields,
		);
	}

	/**
	 * One-time sweep of the orphaned proactive-snapshot options. An install that ran
	 * the old settings-save hook still has `woocommerce_pos_sync_config_fp_<collection>` rows
	 * in `wp_options` that NOTHING reads. Deleting the writer left the data behind;
	 * this removes it on the next upgrade.
	 *
	 * Deletes by EXACT key over the known COLLECTIONS rather than a
	 * `LIKE 'woocommerce_pos_sync_config_fp_%'` scan: the namespace is a closed set, so the
	 * exact-key form needs no $wpdb query and cannot collide with a future option that
	 * happens to share the prefix. Only the barcode collections were ever written, but
	 * sweeping the full COLLECTIONS set costs one extra no-op delete and catches a
	 * stray row from any revision.
	 *
	 * Idempotent and correctness-neutral: the endpoint recomputes the fingerprint from
	 * live options as the sole source of truth, so removing these rows cannot change a
	 * served value.
	 */
	public function maybe_cleanup_legacy_options(): void {
		if ( (int) get_option( self::CLEANUP_VERSION_OPTION, 0 ) >= self::CLEANUP_VERSION ) {
			return;
		}

		foreach ( self::collections() as $collection ) {
			delete_option( self::LEGACY_PROACTIVE_OPTION_PREFIX . $collection );
		}

		update_option( self::CLEANUP_VERSION_OPTION, self::CLEANUP_VERSION, false );
	}

	/** Collections whose served representation depends on the barcode setting. */
	/**
	 * Registry projection: the collections with barcode representation settings.
	 */
	private static function barcode_collections(): array {
		$barcode = array();
		foreach ( Collections::with( 'fingerprint' ) as $collection => $row ) {
			if ( $row['fingerprint']['barcode'] ) {
				$barcode[] = $collection;
			}
		}

		return $barcode;
	}
}

```
