`_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:` 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). * * # Skipped-release caveat for the collections #1756 phase 1 added * * A client cold-adopts a fingerprint key it has never stored, so a contract bump for one of * the six phase-1 collections only reaches tills whose server passed through a release that * served the key at the OLD version first — a server upgrade that skips straight past phase 1 * cold-adopts at the new version with no re-pull. Between phase 1 and the 1.11.0 protocol * gate this is moot (recipe changes are batched AT the gate, whose forced resync covers * them); if a pre-gate bump for one of the six is ever needed, it needs a first-seen * migration protocol first (#1756 phase 2/3 territory — see the issue). * * ADR 0036 extends that rule to ANY collection serving-recipe change: serializer shape, digest * formula key sets (DIGESTED_META_KEYS / CUSTOMER_DIGESTED_META_KEYS in Digest_Index), or the * augmentation set. Bump that collection's version IN THE SAME COMMIT; the fingerprint move is * what triggers the client re-pull that a silent formula change never did. * * # 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 */ /** * 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( // products: product serialization + DIGESTED_META_KEYS formula + barcode augmentation. 'products' => 1, // 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: variation serialization + shared DIGESTED_META_KEYS formula + barcode augmentation. 'variations' => 2, // orders: order serialization + the HPOS/CPT order digest formula. 'orders' => 1, // customers: customer serialization + CUSTOMER_DIGESTED_META_KEYS formula. 'customers' => 1, // categories: product-category term serialization + its augmentation set. 'categories' => 1, // brands: product-brand term serialization + its augmentation set. 'brands' => 1, // tags: product-tag term serialization + its augmentation set. 'tags' => 1, // coupons: coupon serialization + its augmentation set. 'coupons' => 1, // tax_rates: tax-rate serialization + its augmentation set. '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:` 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_` 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, so * sweeping the now-universal membership (nine collections since #1756) costs a * handful of no-op deletes on a fresh install and nothing on an already-swept one * (the CLEANUP_VERSION latch above), while catching a stray row from any revision * that DID write one. * * 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; } }