# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.11.7] - 2026-07-25

### Changed

- **WordPress.org Plugin Check / guideline compliance** — text domain
  is now `seekmodo` (matches Directory slug); removed `Update URI` and
  `WporgUpdateClient` so updates can flow through wordpress.org;
  suggest "Powered by" branding defaults off; privacy-policy guide
  text discloses IP / user agent / referer; release zip excludes
  `AGENTS.md`, `MIGRATION.md`, and `.gitkeep`.
- Escaping / SQL / VIP Plugin Check ERROR cleanups (`wp_strip_all_tags`,
  prepared queue selects, `ABSPATH` guard on `functions.php`, etc.).

## [0.11.6] - 2026-07-25

### Added

- **WordPress.org submission readiness** — locally vendored
  `@seekmodo/web-components` ESM at `assets/web-components/index.js`
  (SDK inlined; source map with `sourcesContent`); default enqueue no
  longer hits `cdn.seekmodo.com`. Filter `seekmodo_web_components_src`
  still overrides. Suggested privacy-policy text via
  `wp_add_privacy_policy_content`. Listing screenshots under
  `assets/admin/screenshot-*.png`.

### Changed

- **Cart cascade** — multi-line carts rank recommendations by
  `support_count` (how many cart anchors returned each doc), then
  score and source priority; up to 10 anchors queried
  (`meta.anchor_cap`). `also_bought` cold-start sources
  (lexical/category_peer/trending) rejected in cart and PDP bought.
- **`Tested up to: 7.0`** (verified on Pocomarine production).
- **`uninstall.php`** — deletes all connector options, local-index
  tables/options, related transients, and cron hooks (including
  `seekmodo_wp_local_index_tick`).
- **HealthWidget** gated on `manage_options`.

## [0.11.5] - 2026-07-24

### Added

- **PDP / cart recommendation cascades** — `RecommendationsCascade`
  composes `also_bought` / `related` / `also_viewed` / `bundle.suggest`
  plus category peers with cross-section de-dupe (AKS parity). REST
  `GET|POST /wp-json/seekmodo/v1/recommendations` serves `pdp-cascade`
  and `cart` (plus legacy single-algo placements). Cart passes all line
  product IDs as `exclude_doc_ids`. Gateway 402 / null fails soft.
- **Storefront auto-inject** — placeholders on
  `woocommerce_after_single_product_summary` and cart hooks; cascade JS
  refreshes after WooCommerce cart fragments; CDN
  `@seekmodo/web-components` enqueued on PDP/cart even without blocks.
  Existing Gutenberg / Elementor / WPBakery recommend blocks unchanged.
- **`Client::recommend()`** — HMAC-signed allowlisted recommend.* /
  bundle.suggest calls.

## [0.11.4] - 2026-07-17

### Fixed

- **ATC telemetry false drop (POCO Marine)** - keep skipping cart-session
  restore adds, but stop requiring REQUEST fingerprints (`add-to-cart` /
  `wc-ajax` / Store API). Custom theme AJAX was failing that check, so
  real shopper ATCs never queued after 0.11.3.
- **Purchase thankyou miss** - also emit purchase events on
  `woocommerce_payment_complete`, deduped against `woocommerce_thankyou`,
  for gateway/redirect checkouts that never render order-received.

## [0.11.3] - 2026-07-16

### Fixed

- **Events flush timeout / ATC storm (POCO Marine)** - `/v1/events` flushes
  now use an 8s timeout (search stays at 250ms). Batches prefer clicks and
  cap conversion rows at 10. A wedged 200-event ATC backlog had been timing
  out, never deleting, and replaying the same pre-upgrade `btk_*` carts to
  the gateway hundreds of thousands of times/day while blocking click drain.
- **Skip cart-session ATC mirrors** - ignore `woocommerce_add_to_cart` during
  `woocommerce_load_cart_from_session` and require an intentional add
  (form / `wc-ajax` / Store API).
- **SERP `search_event_id` cookie fallback** - persist `sm_seid` when
  SearchSwap records a gateway search id so the beacon can attribute clicks
  even when footer localization races.

## [0.11.2] - 2026-07-13

### Fixed

- **Shopper session + visitor IP/UA on search and click events** - commerce
  `SearchSwap` (via `SearchResponse::prepareParams`) and the click beacon now
  stamp per-visitor `session_id` (`sm_sid` cookie / Woo session) plus real
  `ip` / `ua` / `referer`. Events were previously flushing with the connector
  User-Agent and origin Cloudflare edge IP, so bot-check could not classify
  scrapers and session-aware fleet linkage had zero sample (POCO Marine).
- **Drop unlinked SERP beacon clicks** - JS and REST refuse `surface=serp`
  clicks without `search_event_id`, stopping the ~75% unlinked click flood
  (same keyword+product replayed hundreds of times/hour from cached SERPs).

## [0.11.1] - 2026-07-12

### Fixed

- **SERP click LTR linkage** - commerce SearchSwap now stashes gateway
  `meta.search_event_id`; `seekmodo-beacon.js` + SERP link stamps
  (`data-seekmodo-seid`) thread it on click. Typeahead already linked;
  full SERP clicks were landing with `search_event_id=0` (POCO Marine
  fleet-health `beacon_linkage_low`).
## [0.11.0] - 2026-07-08

### Added

- **Local inverted index (Enhanced Native)** â€" MySQL `wp_seekmodo_*` tables with
  TFÃ-IDF relevance, partial matching, AND/OR + OR-fallback, quoted phrases,
  stopwords, local synonyms/pins/rescue, and query logging. No subscription
  required.
- **`wp seekmodo local_index`** â€" build, status, and doctor subcommands.
- **`numinix_seekmodo_local_search()`** â€" programmatic grouped-search API with
  `meta_query` two-phase filtering.
- **Relevanssi compat (opt-in)** â€" honor `'relevanssi' => true` / `'seekmodo' => true`
  on secondary `WP_Query` instances; `seekmodo_do_query()` and filter aliases.
- **Vertical isolation** â€" content vs commerce index builds gated by tenant
  vertical and WooCommerce presence (Trek/POCO/FNHA profiles).
- **Admin UI** â€" Local Index panel under Settings â†' Seekmodo Search.
- **Excerpt helpers** â€" `seekmodo_get_search_excerpt()`, `seekmodo_highlight_title()`.

### Changed

- Enhanced Native content/commerce retrieval uses local index when built;
  falls back to WP/Woo LIKE when index is empty.
- Typeahead fallback uses index-backed prefix search when available.

## [0.10.3] - 2026-07-06

### Changed

- Rebundled `@seekmodo/web-components` v0.3.11 â€" two-phase suggest sends
  `complete:true` for bare numeric product IDs and multi-word queries
  (`needsSerpUpgrade`); pairs with gateway PR #184 SerpPreview on
  `complete:false` numeric ids. Internal product ID search remains
  gateway-side (`id` field) for WooCommerce posts.

## [0.10.2] - 2026-07-03

### Added

- **Keyword redirect server 302** â€" `Commerce\SearchRedirect` on WooCommerce
  search reads snapshot `redirect_rules` and issues a 302 before SERP render.
- **Suggest redirects block** â€" rebundled `@seekmodo/web-components` v0.3.4
  (Go to row + auto-navigate on exact match).

### Fixed

- **Suggest tab-switch thumbnails** â€" rebundled `@seekmodo/web-components`
  v0.3.7 (eager thumbs, `scheduleThumbRecovery`, `_smrv` cache-bust) fixes
  blank product thumbnails after returning from a background tab while the
  suggest dropdown stays open.

## [0.10.1] - 2026-07-03

### Fixed

- **Suggest tab-switch thumbnails** â€" refreshed `seekmodo-suggest.global.js`
  (`@seekmodo/web-components` v0.3.3) loads product thumbnails eagerly and
  reloads stalled images on `visibilitychange`, fixing blank gray thumb slots
  after switching browser tabs while the suggest dropdown stays open.

## [0.10.0] - 2026-06-27

### Added

- **WooCommerce conversion events** â€" mirrors `add_to_cart` and `purchase`
  telemetry to the gateway events queue via `woocommerce_add_to_cart` and
  `woocommerce_thankyou` hooks (LTR funnel parity with Zen Cart).

## [0.8.9] - 2026-06-25

### Added

- **Content thumbnail resolver:** `ThumbnailUrl::forPost()` resolves featured
  image â†' first inline content image â†' `seekmodo_thumbnail_url` filter.
  Typeahead, indexer, and enhanced-native retriever now share this chain so
  pages without featured images can still surface thumbnails in suggest/SERP.
- **Suggest content labels:** Refreshed `seekmodo-suggest.global.js` with
  post-type badges (Page / Article / Tool) and styled empty-thumb placeholders.

## [0.8.8] - 2026-06-25

### Fixed

- **Content typeahead parity:** The REST `/typeahead` route now unions
  gateway hits with native WordPress rows for the content vertical, then
  re-ranks by the operator's post-type priority (`page` before `post` on
  trek.ca). Partial gateway indexes no longer hide pages that the SERP
  already surfaces.
- **Suggest typeahead fallback:** `<seekmodo-suggest>` always merges the
  connector REST `/typeahead` response when `typeahead-fallback-url` is
  set, not only when the gateway products block is empty.

## [0.8.7] - 2026-06-25

### Fixed

- **Commerce SERP products:** WooCommerce product search now applies
  gateway-ranked `post__in` after Woo finishes building the product
  query (priority 999). The earlier priority-9 swap was overwritten by
  Woo's catalog ordering, which left shoppers on a category grid instead
  of ranked products. Keeps the shopper keyword in the query for
  `is_search()` / titles and suppresses native SQL via `posts_search`.
- **Suggest typeahead fallback:** `<seekmodo-suggest>` now merges the
  connector REST `/typeahead` fallback when the gateway `/v1/suggest`
  products block is empty, even if other suggest blocks (keywords,
  trending, etc.) returned data. Fixes content sites like trek.ca where
  the SERP native fallback worked but the dropdown stayed empty.

## [0.8.6] - 2026-06-25

### Fixed

- **Commerce SERP title:** WooCommerce product search pages no longer
  render `Search results: ""` after the gateway swap clears the query
  `s` var. The connector now preserves the shopper keyword for
  `get_search_query()` while still suppressing native SQL LIKE via
  `posts_search`.

## [0.8.5] - 2026-06-23

### Fixed

- **Mobile split-rail slider:** Draggable divider expands/contracts both the
  keyword rail and product grid again (rail `max-height` cap scoped to static
  mobile stack only).

### Changed

- Refreshes the vendored `@seekmodo/web-components` suggest bundle with the
  split-rail resize fix.

## [0.8.3] - 2026-06-22

### Added

- **Typeahead LTR linkage:** `/v1/suggest` `meta.search_event_id` threads
  through product-row click beacons for Learning-to-Rank click attribution
  when shoppers pick a product from the suggest dropdown.

### Changed

- Refreshes the vendored `@seekmodo/web-components` suggest bundle (split-rail
  mobile divider, native title tooltips, anchor performance fixes).

## [0.8.2] - 2026-06-21

### Fixed

- **Click attribution:** Typeahead product-row clicks and WP search swap
  paths fire click beacons with correct rank/surface metadata (parity with
  Zen Cart connector v1.2.1+).

### Changed

- Refreshes the vendored suggest bundle with split-rail scroll and thumb
  scaling fixes.

## [0.8.1] - 2026-06-20

### Added

- **CORS-block UX:** When gateway script loads or suggest fetches are
  blocked by the browser, storefronts show an inline notice where the
  dropdown would appear. Ships `seekmodo-cors-notice.js` before the
  suggest bundle, listens for `seekmodo-suggest:cors-blocked`, and
  surfaces the same message on legacy typeahead gateway-direct failures
  when the REST fallback also returns empty.

- Refreshes the vendored `@seekmodo/web-components` suggest bundle to
  v0.2.2 (SDK `isLikelyCorsBlock` + in-component notice panel).

## [0.8.0] - 2026-06-20

### Added

- **Enhanced Native layer (Tier 1 + Tier 2):** Multi-field WooCommerce and
  content retrieval, local typeahead fallback, cached synonyms/pins/rescue
  from `tenant.snapshot`, LKG cache, and local click bias. Runs without
  pairing via `numinix_seekmodo_enhanced_native_enabled()`; gateway path
  unchanged for subscribed tenants.

- **Gate split:** `numinix_seekmodo_gateway_enabled()` vs
  `numinix_seekmodo_enhanced_native_enabled()` so installs deliver search
  improvements before Seekmodo pairing.

- **Suggest UI degradation:** On empty suggest responses, falls back to
  the REST typeahead endpoint with Enhanced Native rows.

## [0.7.6] - 2026-06-19

### Added

- Branded connector documentation (`README.html`, `license.txt`, `assets/`)
  at the zip root, rendered from `connector-docs/wordpress.yaml` via the
  shared seekmodo `packages/connector-docs` template (same layout as Zen Cart).

## [0.7.3] - 2026-06-19

### Fixed

- **Commerce SERP click attribution (LTR):** WooCommerce product-search
  result loops now stamp `data-seekmodo-id` / `data-seekmodo-pos` /
  `data-seekmodo-surface="serp"` on loop product links whenever a
  Seekmodo commerce swap is active (`/?s=â€¦&post_type=product`). The
  bundled `seekmodo-beacon.js` could never fire on storefronts like
  pocomarine.com because `woocommerce_template_loop_product_link_open()`
  emits a bare `<a href>` with no attribute hook â€" clicks were silently
  dropped despite healthy search + impression telemetry.

- **Beacon enqueue on Woo product search:** `ClickBeacon` now also
  enqueues `seekmodo-beacon.js` when the request carries a product
  search query string, not only when `is_search()` is already true.

## [0.7.2] - 2026-06-15

### Changed - suggest dropdown default width 320 â†' 480 px

The vendored `seekmodo-suggest.global.js` bundle (sourced from
`@seekmodo/web-components@0.2.1`) now anchors with a 480 px default
`anchor-min-width` instead of the previous 320. Catalog-grade product
names like `Handy Standard SBC990 Snowmobile Lift` and
`Redline TR1500 Trailer` were truncating at ~15 chars in the
typeahead dropdown when the bound storefront input was the typical
~280 px width â€" the 480 default fits ~35 chars of name plus the
price column and thumbnail without wrapping. A new viewport-right
clamp keeps the dropdown from painting off-screen when a narrow
mobile viewport can't accommodate 480 px (the rendered width caps
at `viewport.right - input.left - 8 px gutter`).

No PHP / autoload changes â€" only the bundled JS asset shifts. WP
sites that override `anchor-min-width` on their own
`<seekmodo-suggest>` tag (none currently) keep their explicit value.

## [0.7.1] - 2026-06-14

### Fixed - vendor/autoload.php was never required

The 0.7.0 SDK-adoption ship vendored `numinix/seekmodo-connector ^0.2.0`
and `guzzlehttp/psr7` under `vendor/`, but the plugin's main file
(`seekmodo-wordpress.php`) only registered its own PSR-4-ish autoloader
for `Numinix\SeekmodoWordPress\*`. Calls into `\Numinix\SeekmodoSdk\*`
(via the new `Numinix\SeekmodoWordPress\Sdk\Bootstrap` helper or any
of the WordPress storage adapters) would fatal at class-resolution time.

The legacy gateway path (`Gateway\Client`, `Mode\ModeFsm`, etc.) was
unaffected because it doesn't reach into the SDK namespace - which is
why the storefront kept serving 200s after the 0.7.0 ship despite the
bug. But any code that called `Sdk\Bootstrap::client()` would die.

`seekmodo-wordpress.php` now `require_once`s `vendor/autoload.php`
right before the existing `spl_autoload_register`. The legacy
autoloader stays in place as a safety net so the plugin keeps booting
even if `vendor/` is stripped (e.g. by a partial deploy or hot-edit).

Discovered during the cross-site rollout pass on `pocomarine.com` and
`trek.ca`, where `wp eval 'echo class_exists("Numinix\\SeekmodoSdk\\Client") ? "ok" : "missing";'`
returned `missing` immediately after the 0.7.0 deploy. No other code
changes ship in 0.7.1.

## [0.7.0] - 2026-06-14

### Added - shared PHP SDK adoption (phase 4 of the connector migration)

The shared transport / breaker / mode-FSM / pairing / events code
lifted out into a new Composer package, `numinix/seekmodo-connector`
(PSR-4 root `Numinix\SeekmodoSdk\`). The WP connector now requires
it (`composer require numinix/seekmodo-connector ^0.2`) and ships
three platform adapters:

- `Numinix\SeekmodoWordPress\Storage\WpTransientBreakerStore` â€"
  implements `Numinix\SeekmodoSdk\Storage\BreakerStateStore` over
  WP transients (auto-promotes to a persistent object cache when
  Memcached / Redis is installed).
- `Numinix\SeekmodoWordPress\Storage\WpObjectCache` â€" implements
  `Numinix\SeekmodoSdk\Storage\CacheInterface` for tenant.snapshot
  polling, browser-token caching, JWKS caching, and the
  signature-mismatch sliding window.
- `Numinix\SeekmodoWordPress\Storage\WpOptionsEventQueueStore` â€"
  implements `Numinix\SeekmodoSdk\Storage\EventQueueStore` over a
  single `wp_options` row (autoload off).
- `Numinix\SeekmodoWordPress\Sdk\WpHttpClient` â€" PSR-18 HTTP client
  backed by `wp_remote_request`, so the plugin doesn't ship Guzzle.
- `Numinix\SeekmodoWordPress\Sdk\Bootstrap` â€" lazy-singleton factory
  that wires the SDK with the WP adapters + the connector's existing
  `Options` config reader. New code can call
  `\Numinix\SeekmodoWordPress\Sdk\Bootstrap::client()->search([...])`
  to use the shared SDK directly.

### Unchanged in 0.7.0 (intentional, for storefront stability)

- `Numinix\SeekmodoWordPress\Gateway\{Client,Hmac,CircuitBreaker,
  BrowserToken,SignatureMismatchTracker}` are still present and
  still the entry points the rest of the plugin uses.
- `Numinix\SeekmodoWordPress\Mode\{ModeFsm,AutoPromoter,
  ShadowDiffLogger}` unchanged.
- `Numinix\SeekmodoWordPress\Connect\Pairing` unchanged.
- `Numinix\SeekmodoWordPress\Events\{EventsQueue,ClickBeacon}`
  unchanged.

See [MIGRATION.md](MIGRATION.md) for the 0.7 â†' 0.8 collapse-to-adapter
plan.

### New runtime composer deps

- `numinix/seekmodo-connector ^0.2`
- `guzzlehttp/psr7 ^2.5` (PSR-7 message implementation used by the
  SDK and `WpHttpClient`; ~50KB, no transitive HTTP dependencies)

## [0.6.2] - 2026-06-14

### Fixed - clicking a suggest row was a no-op

The `<seekmodo-suggest>` web component is intentionally inert on row
click: it emits a `seekmodo-suggest:row-click` CustomEvent
(`composed: true`, bubbles to `document`) and leaves the connector
to decide where to send the shopper - see the source comment in
`services/web-components/src/components/suggest.ts` around
`activateRow()`. The 0.6.0 / 0.6.1 autoboot wired the markup that
*attaches* `<seekmodo-suggest>` next to each search input but
forgot the listener that *navigates* on the event, so clicking a
result felt completely dead - the row visually highlighted, the
input briefly stole focus back, then nothing happened. The same
bug shipped in lockstep on the Zen Cart connector (v1.0.22) and
the BigCommerce connector (v0.9.5/0.9.6), so this is the
WordPress half of a coordinated fix-pack.

`SuggestUI::buildAutobootScript()` now appends a
`document.addEventListener('seekmodo-suggest:row-click', â€¦)`
handler inside the same IIFE so it has the `CFG.view_all_href`
template in scope.

Behaviour:

- Products / categories with `row.url` â†' `location.href = row.url`.
- Keyword-style blocks (`recent`, `trending`, `keywords`,
  `did_you_mean`) â†' `view_all_href` with `{q}` replaced by the
  row's keyword text.
- Products / categories that happen to lack `row.url` â†' the same
  view-all fallback using `row.name` (defensive; the indexer
  stamps `url` today, but older posts may not).

Pure additive change to the inline autoboot template - no other
files touched, no plugin schema or DB change.

## [0.6.1] - 2026-06-12

### Changed - SM-606 follow-up: pick up self-anchoring bundle

- `assets/suggest/seekmodo-suggest.global.js` - refreshed pinned
  bundle to the SM-606 self-anchoring build (numinix/seekmodo
  commit e5f6090, branch `feat/SM-606-widget-self-anchor`). The
  widget now positions itself as a `position:fixed` overlay
  anchored to its bound input by default and exposes
  `anchor` / `anchor-offset` / `anchor-min-width` /
  `suppress-legacy` attributes. Bundle is 8.4 KB gzipped (up
  ~1.15 KB; still under the 12 KB SDK budget).
- `src/Frontend/SuggestUI.php` - default `extras` now includes
  `'suppress-legacy' => 'seekmodo-typeahead'` so sites that still
  enqueue the Sprint-7 `<seekmodo-typeahead>` widget (the v0.4.0
  vanilla-JS dropdown) don't double-render two competing
  typeaheads. Themes can drop the entry via the existing
  `seekmodo_suggest_extra_attrs` filter to opt out, or set it to
  `'jquery-ui,seekmodo-typeahead'` (CSV) on storefronts that also
  enqueue jQuery UI autocomplete on the same input.
- No backward-compatibility breaks: the `anchor="auto"` default
  is opt-out (set `anchor="none"` on the element for legacy
  in-DOM rendering), and the `suppress-legacy` default targets
  only sibling widgets bound to the same input id - unrelated
  forms (Woo cart, wishlist) are untouched.

## [0.6.0] - 2026-06-12

SM-606 Universal Suggest Widget. v0.6.0 ships the
`<seekmodo-suggest>` web component as the connector's default
storefront typeahead surface, replacing the v0.4.0-era flat-row
vanilla-JS dropdown. The new widget reads the rich `/v1/suggest`
envelope (WS-2 of the universal-suggest-widget plan) and renders
seven content blocks â€" recent, did-you-mean, keywords, trending,
products, categories, "View all N results" CTA â€" from one server
round-trip. The same bundle ships on every Seekmodo connector
platform (BigCommerce, Zen Cart, AKS, WordPress) so a tenant with
multiple storefronts sees one suggestions UX across all of them.

### Why

The v0.4.0 dropdown was a per-connector vanilla-JS file; four
connectors meant four near-copies that drifted independently and four
places to land every UX iteration. The new web component lives in
`@seekmodo/web-components` and is enqueued verbatim by every
connector â€" when we ship a tweak to the dropdown's keyboard nav or
attribution beacon, every storefront inherits it on the connector's
next release.

The rich envelope also turns the dropdown from "ten typed product
names" into a real suggestions surface: returning shoppers see their
recent searches; cold-prefix shoppers see what other shoppers on the
same tenant are typing right now; misspellings get a one-click swap
to the corrected query. The competitive bar for storefront search is
no longer "1-row-per-product"; this release lifts our floor to match.

### Changed

- **`<seekmodo-suggest>` is the default UI** when the operator-facing
  "Enable connector typeahead UI" toggle is on. The legacy
  `Frontend/TypeaheadUI` is gated on the new
  `seekmodo_wp_suggest_legacy` per-site flag and only ships when the
  operator explicitly opts in (one major-version compat window).
- **Asset surface**: enqueues
  `assets/suggest/seekmodo-suggest.global.js` (~23 KB raw / ~7 KB
  gzip â€" under the 12 KB plan target). The legacy
  `assets/typeahead/seekmodo-typeahead.{js,css}` stays in the
  distribution for the next major-version cycle so legacy-opt-in
  sites keep working.

### Added

- `Frontend/SuggestUI` â€" meta-tag emission (`seekmodo:tenant`,
  `seekmodo:gateway`, `seekmodo:refresh`, `seekmodo:token`), bundle
  enqueue, and the inline autoboot that walks the selector list and
  spawns a `<seekmodo-suggest input="â€¦">` sibling per matching input.
  Mirrors the legacy `Frontend/TypeaheadUI` gating posture so
  existing site-level filters (`seekmodo_show_branding`,
  `seekmodo_enqueue_typeahead` analog `seekmodo_enqueue_suggest`)
  keep working.
- `Rest/BrowserTokenRoute` â€" public
  `POST /wp-json/seekmodo/v1/browser-token` route returns the
  cached gateway-direct token + its expiry. Lets a long-running
  storefront tab refresh the JWT without a page reload (5-min TTL).
  Permissionless on purpose â€" the surface it unlocks is the same
  inline `<meta name="seekmodo:token">` the page HTML already
  publishes, so adding a nonce would only add UX friction without
  closing any new attack surface.
- `Config\Options::OPT_SUGGEST_LEGACY` â€" the per-site opt-out flag.
  Registered via the standard Settings API path, sanitized through
  `sanitizeBool()`, default `0`.

### Customization

Theme-side hooks for the new widget are all named
`seekmodo_suggest_*` for grep-ability:

- `seekmodo_suggest_selectors` â€" CSS-selector list the autoboot
  walks. Default: `input[type="search"]`, `input[name="s"]`,
  `input[data-seekmodo-suggest]`, `input[data-seekmodo-typeahead]`.
- `seekmodo_suggest_blocks` â€" CSV of blocks to render in order.
  Default: `recent,did_you_mean,keywords,trending,products,categories`.
  Drop a block by leaving it out.
- `seekmodo_suggest_view_all_href` â€" URL template for the
  "View all N results" CTA. Default: `/?s={q}` (or
  `/?s={q}&post_type=product` on Woo sites).
- `seekmodo_suggest_extra_attrs` â€" assoc map of additional
  attributes (e.g. `min-length`, `debounce-ms`, `limit`,
  `cache-size`, `lang`) forwarded to every spawned
  `<seekmodo-suggest>` element.
- `seekmodo_suggest_config` â€" last-resort filter on the localized
  JS config object.

Shadow-DOM-level theming (CSS Custom Properties, `::part(...)` CSS
parts, `<slot name="â€¦">` block overrides, and the
`window.seekmodoSuggest.renderRow` hook) is documented in the
web-component's source header.

### Performance

Bundle size 22.6 KB raw / 7.25 KB gzip (esbuild treeshake-minified).
Built-in 150 ms keystroke debounce, AbortController cancel on next
keystroke, 32-entry LRU cache, single rAF-batched render, and a
skeleton loader masking the first-render latency on a cold cache.
Matches the AKS-3801e baseline (sub-300 ms p95 cold cache, <16 ms
warm-cache render).

## [0.5.3] - 2026-06-11

Two follow-ups from the PM-303 incident at POCO. One operator-UX paper
cut surfaced while reindexing the commerce vertical to clean up
dev-side URLs in the prod Typesense collection; one safety hole
surfaced while auditing how the dev install ended up writing those URLs
in the first place.

### Why

**Operator UX.** During PM-303 cleanup we discovered that
`wp seekmodo index --full` only invoked
`Numinix\SeekmodoWordPress\Content\Indexer::reindexFull()`. Reindexing
the commerce vertical required dropping into

    wp eval '\Numinix\SeekmodoWordPress\Commerce\Indexer::reindexFull();'

which is fine for an incident-response operator but unfriendly for the
many merchants who only run a commerce vertical and never need the
content path. The auto-cron tick (`Commerce\Indexer::tick()`) was wired
correctly; only the CLI manual entry point was content-only.

**Safety.** The PM-303 root cause was the dev WordPress install at
`dev.pocomarine.com` writing `dev.pocomarine.com` URLs into the
`pocomarine` tenant's Typesense collection. The audit was:

  - Dev's `seekmodo_wp_storefront_host` option = `dev.pocomarine.com`
    (auto-seeded from `home_url()` at activation).
  - Dev's home_url() host = `dev.pocomarine.com`.
  - `DomainLock::matches()` returned `true` (current==locked).
  - `numinix_seekmodo_run_bulk_upsert()` therefore happily pushed every
    product save to the same tenant prod was paired to.
  - Dev's tenant pair (`tenant_id = pocomarine`, same as prod) meant
    every dev write landed in the customer-visible Typesense
    collection.

The fix needs to fail closed by default on this exact shape â€" non-prod
host (`dev.*`, `staging.*`, ...) with a self-referential lock â€" without
breaking legitimate prod installs (where `current==locked==www.foo.com`
which doesn't trip the heuristic).

### What changed

#### CLI

* `wp seekmodo index --full` now reindexes every gateway-enabled
  vertical for the tenant by default (content + commerce, in series).
* New `--vertical=<vertical>` flag accepts `content`, `commerce`, or
  `all` (default `all`). Surgical reindexes are now `wp seekmodo index
  --full --vertical=commerce` instead of a `wp eval`.
* Verticals the gateway hasn't enabled â€" or whose runtime dependency
  is missing (e.g. WooCommerce inactive on a content-only site) â€" are
  skipped silently with an info-level log line. The command reports
  per-vertical totals separately (`indexed=3289 failed=0 (commerce[
  indexed=3289 failed=0])`) so the operator can tell at a glance
  whether each vertical actually executed.
* New write-side host-lock pre-flight: the CLI checks
  `numinix_seekmodo_can_index()` BEFORE walking any post tables and
  exits with a clear error message that includes the host, lock,
  and the bypass-filter snippet. Avoids the "I ran `wp seekmodo
  index --full` on dev and it printed `indexed=0 failed=3289`"
  confusion.

#### DomainLock

* New `DomainLock::canIndex()` is the stricter write-side companion
  to the pre-existing `DomainLock::matches()`. Read paths (typeahead,
  search-swap, click-beacon enqueue) keep gating on `matches()`; every
  gateway-mutating helper (`numinix_seekmodo_run_bulk_upsert`,
  `numinix_seekmodo_mirror_click`) now gates on `canIndex()`.
* `canIndex()` fails closed when:
  - The host shape is non-prod per `DomainLock::looksLikeNonProd()`
    (matches `dev`, `staging`, `stage`, `test`, `testing`, `qa`,
    `preview`, `beta`, `preprod`, `pre-prod`, `sandbox` as the
    leftmost label of the current host).
  - â€¦regardless of whether the lock is self-referential or unset.
    A freshly-installed dev clone that hasn't pulled its first
    `tenant.snapshot` yet (where `lockedHost === ''`) is the
    second-most-common foot-gun and pre-0.5.3 also returned true
    from `matches()`.
* Two override filters, intentionally separated so a read-side bypass
  doesn't accidentally also unlock writes:
  - `seekmodo_disable_storefront_host_lock` (existing) â€" read AND
    write; bypasses every check. Used by blue/green CDN setups.
  - `seekmodo_allow_nonprod_indexing` (new) â€" write only; consenting
    non-prod indexer for installs paired to a separate `*-dev`
    tenant. Defaults false.

#### Indexer cron tick

* `Content\Indexer::tick()` and `Commerce\Indexer::tick()` early-return
  when `numinix_seekmodo_can_index()` is false. Pre-0.5.3 a dev install
  walked every dirty post / product on every 15-min tick just to
  no-op the bulk upsert at the host-lock check. POCO's commerce-only
  case had ~3,289 products; the wasted work is now zero.

#### Health surface

* `wp seekmodo health` now prints a richer host-lock breakdown:
  `current_host`, `locked_host`, `host_lock_match`, `can_index`,
  `looks_like_nonprod`. Pre-0.5.3 it printed only `host_lock_match`,
  which made "why isn't dev indexing?" investigations require a
  `wp eval` round-trip.
* `wp seekmodo diagnose` JSON bundle adds the same fields plus
  `locked_host` (which was already inferable from
  `current_host` when `host_lock_match=true` but wasn't surfaced
  separately).

### Compatibility

* Drop-in PHP patch; no DB / option changes; no gateway-side schema
  changes.
* Existing scripts that run `wp seekmodo index --full` and expect
  ONLY the content vertical to fire will see the commerce indexer
  also run if and only if (a) the gateway has commerce enabled for
  the tenant AND (b) WooCommerce is active. Mixed-vertical sites
  that DO NOT want the commerce path picked up should pass the
  explicit `--vertical=content` flag.
* Existing `numinix_seekmodo_run_bulk_upsert()` callers (no internal
  consumers; theme/plugin overrides are extremely rare) inherit the
  stricter gate transparently. Sites legitimately indexing from a
  non-prod host can opt back in via the new
  `seekmodo_allow_nonprod_indexing` filter.
* The post-deploy cache-flush self-heal added in v0.5.1 fans out
  on the version bump from `0.5.2 â†' 0.5.3` so operators don't need
  to manually purge WP Rocket / LiteSpeed / Cloudflare.

#### Drive-by: `wp seekmodo <subcommand>` was broken since 0.1.0

Discovered post-deploy on POCO during 0.5.3 verification. WP-CLI
treats any class with `__invoke()` as an *atomic* single-handler
command and silently ignores the public-method subcommand
convention. The connector's `Cli\Commands` class shipped with
`__invoke()` on day one (intended as a "version stub" for
`wp seekmodo`), which meant every subcommand below it was
inaccessible: `wp seekmodo health`, `wp seekmodo index`,
`wp seekmodo diagnose`, `wp seekmodo connect`, etc. all routed to
`__invoke` and printed the version banner instead of running.
`wp help seekmodo` correspondingly listed zero subcommands. The
reflection probe on POCO prod confirmed all methods were live in
the class, just unreachable through WP-CLI's command surface.

Fix: drop `__invoke`. WP-CLI's standard usage banner takes over
for `wp seekmodo` (no args) and auto-lists every public method as
a subcommand. Every subcommand documented in the readme now
actually works. CLI integrators who scripted `wp seekmodo` with no
args expecting the version stub will now see the WP-CLI subcommand
listing instead â€" same exit code (0), more useful output.

### Files touched

* `src/Cli/Commands.php` (`index`, `health`, `diagnose` subcommands;
  `__invoke` removed for the WP-CLI subcommand-routing fix above)
* `src/Config/DomainLock.php` (`canIndex()` + extended docstrings)
* `src/Helpers/functions.php` (`numinix_seekmodo_can_index()` helper;
  routing of `numinix_seekmodo_run_bulk_upsert` and
  `numinix_seekmodo_mirror_click` through the new gate)
* `src/Content/Indexer.php` (`tick()` early-return)
* `src/Commerce/Indexer.php` (`tick()` early-return)
* `tests/Unit/DomainLockTest.php` (+11 cases covering `canIndex()`)
* `seekmodo-wordpress.php` (`Version:` header + constant â†' `0.5.3`)
* `readme.txt` (`Stable tag:` + Upgrade Notice + Changelog entries)

## [0.5.2] - 2026-06-11

Hotfix for the gateway-direct typeahead row-shape mismatch surfaced
on POCO. Closes PM-303 (storefront-side; the CSS dead-zone fix in
the POCO theme that allowed the search bar to render on resized
desktop viewports exposed this dormant rendering bug).

### Why

The canonical `/v1/typeahead` row shape per
`CONNECTOR_TYPEAHEAD_SPEC.md` is vertical-aware:

* **commerce**: `id, name, brand, price, sale_price, on_sale,
  currency, sku, category_breadcrumbs, image, image_url, url,
  in_stock` â€" note `name`, `url`, `in_stock`.
* **content**:  `id, title, excerpt, thumbnail_url, post_type, url,
  is_sticky` â€" note `title`.

The WP REST proxy endpoint at `/wp-json/seekmodo/v1/typeahead`
historically rewrites commerce rows to a hybrid `title` /
`permalink` / `image_url` / `stock_status` shape so the JS client
can render content + commerce with one code path.

When v0.5.0 wired the storefront browser to fetch
`/v1/typeahead` directly (skipping the WordPress REST hop for
~1.5s/keystroke), the renderer started seeing canonical commerce
rows for the first time. Every commerce row's `r.title` lookup
resolved to `undefined`, the renderer's `if (!title) continue`
skipped every row, the resulting `body` html was the empty string,
and `render()` fell through to `renderEmpty` â€" i.e. the user typed
"anchor", the gateway returned 8 perfectly-formed product rows,
and the dropdown still showed the `No results "anchor"` empty
state.

Live wire trace from www.pocomarine.com (q=anchor) confirmed the
diagnosis:

* `GET /wp-json/seekmodo/v1/typeahead?q=anchor`        â†' 8 rows in
  the legacy hybrid shape. The legacy code path renders these
  correctly today.
* `POST https://mcp.seekmodo.com/v1/typeahead` (browser-direct,
  with the page-render Bearer token + tenant header) â†' 8 rows in
  the canonical commerce shape (`name`, `url`, `image_url`,
  `in_stock`). The renderer skipped all 8.

### Fix

`assets/typeahead/seekmodo-typeahead.js`:

* `renderCommerceRows`: accept `r.name || r.title` and `r.url ||
  r.permalink`. Normalize `r.in_stock` boolean â†' `'instock'` /
  `'outofstock'` for the badge code.
* `renderContentRows`: accept `r.title || r.name` and add
  `r.image_url || r.image` as defensive thumbnail fallbacks.

The legacy WP REST proxy shape continues to work â€" it always
populated the prefer-first key in each `||` chain. There is no
behavior change for sites running content-only verticals or for
sites that haven't unlocked browser-direct fetching (gateway anon
zone disabled, plan tier without a browser token, etc.).

### Compatibility

Drop-in patch. No PHP changes. No DB / option changes. The post-
deploy cache-flush self-heal added in v0.5.1 covers the JS
changeover automatically â€" operators don't need to manually purge
WP Rocket / LiteSpeed / Cloudflare after the upgrade.

### Files touched

* `assets/typeahead/seekmodo-typeahead.js`
* `seekmodo-wordpress.php` (`Version:` header â†' `0.5.2`,
  `SEEKMODO_WP_VERSION` constant â†' `'0.5.2'`)
* `readme.txt` (`Stable tag: 0.5.2`, `Upgrade Notice` for 0.5.2)
* `CHANGELOG.md` (this entry)

### Affected connectors

* WordPress (this repo) â€" fixed in v0.5.2.
* Zen Cart (`seekmodo-zen-cart-connector`) â€" also goes
  gateway-direct as of v1.0.20 with `jscript_seekmodo_typeahead.js`.
  That JS uses its own renderer; spot-check it for the same row-
  shape assumption in a follow-up. Tracking note added to
  `docs/CONNECTOR_TYPEAHEAD_SPEC.md`.
* BigCommerce (`seekmodo-bigcommerce-connector`) â€" proxies via the
  Worker route added in v0.8.0; storefront snippet renderer (per
  `docs/storefront-snippets/typeahead.md`) ships canonical-shape
  examples already, so unaffected.
* AKS custom (`aks-seekmodo-connector`) â€" server-side adapter only.
  No browser-direct path; storefront JS is operator-supplied per
  the v1.5.0 release note. Unaffected.

## [0.5.1] - 2026-06-11

Self-healing post-deploy cache flushes. The connector now detects an
on-disk version bump (`SEEKMODO_WP_VERSION` constant != stored
`wp_options` value) on every request and, on the first mismatch,
fans out cache invalidations to every page-cache / minify / object
cache / OPCache plugin we know about. Closes the "I deployed v0.5.0
but visitors still load v0.4.0" regression that surfaced on POCO
after the v0.5.0 production push.

### Why

WordPress only fires `upgrader_process_complete` when *its own
upgrader* runs the install â€" i.e. the operator clicks "Plugins â†'
Update" in wp-admin, or wp-cli runs `wp plugin update`. Cache plugins
hook that action to clear their stores on plugin upgrade.

But two of our three deploy channels never invoke the upgrader:

* `rsync` from a `git deploy.php` poller (POCO + Trek both use this)
  drops the new files on disk without going through WP at all.
* Manual `mv -f` deploys onto a hosted environment (some agency
  workflows).

Result on POCO post-v0.5.0:

* The v0.5.0 PHP + JS landed correctly on disk and the
  `wp_localize_script` block emitted the new `gateway` browser-token
  payload â€" verified live.
* WP Rocket continued serving its v0.4.0 cached minified JS at
  `/wp-content/cache/min/1/...?ver=1781160307` for **1 year** (the
  default `Cache-Control: max-age=31536000`), which Cloudflare
  edge-cached on top.
* Visitors loaded the v0.4.0 client which doesn't know about
  `CFG.gateway` and continued routing typeahead through the slow
  WP REST hop. The connector itself was functioning end-to-end;
  only the JS-on-disk-vs-served-by-cache delta was wrong.

This release adds a deploy-channel-agnostic detector that catches
all three paths and self-heals on the first PHP request after the
new bytes hit disk.

### Added

* `src/Maintenance/Upgrade.php` â€" version-bump detector + flush fan-
  out. Hooked into `init` priority 1 from
  `Bootstrap::boot()` so it runs after `Options::register()` but
  before any other connector code that might cache on the new
  schema.
* `wp_options` row `seekmodo_wp_active_version` (autoload=false)
  records the last connector version that ran on this site.
* New filters:
  * `seekmodo_skip_upgrade_flush(bool $skip, string $previous, string $current)`
    â€" operator escape hatch. Hosts with their own CI-driven cache
    invalidation can suppress the connector-side fan-out by
    `__return_true`-ing this.
  * `seekmodo_upgrade_flush_actions(array $extra, string $previous, string $current)`
    â€" append extra `do_action(...)` keys to the built-in fan-out
    list. Useful for proprietary cache plugins not in the default
    set.
* New action: `seekmodo_upgrade_flush_complete($previous, $current)`
  fires once after every successful flush. Observability /
  tracker plugins use this to record connector-driven
  invalidations separately from operator-driven ones.

### Cache plugins covered

| Plugin                       | Mechanism                                  |
|------------------------------|--------------------------------------------|
| WP Rocket                    | `rocket_clean_minify()` + `rocket_clean_domain()` |
| LiteSpeed Cache              | `do_action('litespeed_purge_all')`         |
| W3 Total Cache               | `w3tc_pgcache_flush()` + `w3tc_minify_flush()` |
| WP Super Cache               | `wp_cache_clean_cache()` + `wp_cache_clear_cache()` |
| WP-Optimize                  | `do_action('wpo_purge_pagecache')`         |
| Hummingbird                  | `do_action('wphb_clear_page_cache')`       |
| Cache Enabler                | `do_action('cache_enabler_clear_complete_cache')` |
| Pantheon Advanced Page Cache | `do_action('pantheon_wp_clear_edge_all')`  |
| Kinsta MU                    | `do_action('kinsta_cache_purge_all')`      |
| Cloudflare WP                | `do_action('cloudflare_purge_everything')` |
| Breeze (CloudPanel)          | `do_action('breeze_clear_all_cache')`      |
| SG Optimizer (SiteGround)    | `do_action('sg_cachepress_purge_cache')`   |
| Comet Cache                  | `do_action('comet_cache_clear_cache')`     |
| WordPress object cache       | `wp_cache_flush()`                         |
| OPCache                      | `opcache_reset()` (gated on function_exists) |
| Connector browser-token      | `BrowserToken::invalidate()` (transient)   |

### Concurrency model

The version-bump option is updated *before* the flush fan-out and
the `update_option()` return value is used as the gate â€" only the
request that successfully wrote the new value proceeds. Concurrent
first-bump requests racing on the same page-load can't double-fire
the flushes inside the same request window.

### Compatibility

No behaviour change for sites without any cache plugin installed â€"
the function-exists / has_action guards keep every fan-out target
a no-op when its host plugin is absent. The new `wp_options` row
is autoload=false so it stays off the `wp_load_alloptions()` hot
path. Mass-deployable as a no-op upgrade on every connector site.

## [0.5.0] - 2026-06-11

Cuts the per-keystroke typeahead latency from ~2,250 ms to ~80â€"150 ms
on heavy WordPress installs by routing the storefront's typeahead
fetch directly to `mcp.seekmodo.com` instead of round-tripping through
the WordPress REST API. Also closes a long-standing regression where
the gateway 404'd `/v1/typeahead` and the connector silently fell
back to a native `WP_Query` LIKE search on every keystroke.

### Why

POCO.com (WooCommerce + Wordfence) was observing ~2.3 s/keystroke on
its Seekmodo-powered typeahead. Profiling confirmed two compounding
problems:

1. **Gateway 404.** The connector POSTed `/v1/typeahead` and the
   gateway returned `404 tool_not_found` â€" a flat-row typeahead tool
   was missing on the platform side. The `Client::typeahead()` null
   branch interpreted that as "fall back to native code path", so
   every keystroke ran a `WP_Query` LIKE search against the WP DB.
   That part is fixed on the platform side (SM-602; gateway tool +
   APCu micro-cache + Cloudflare edge cache headers).
2. **WordPress REST bootstrap.** Even when the gateway started
   returning real rows, hitting `/wp-json/seekmodo/v1/typeahead`
   booted the entire WordPress stack (plugins + theme +
   WooCommerce + Wordfence + WP Rocket), which on POCO's stack
   alone costs ~1.5â€"2 s on every keystroke. Solved here, in this
   connector update, by skipping WP REST entirely on the typeahead
   hot path.

### Added

- **`src/Gateway/BrowserToken.php`**: cached per-tenant browser-token
  mint. Calls `/v1/tenants/token` server-side, stores the result
  (token + expiry + session id) in a WP transient with a 60 s
  refresh buffer and a hard 240 s cap. Anonymous (per-site, not
  per-shopper) so the token survives a Cloudflare / WP Rocket
  full-page cache without per-user binding. `BrowserToken::invalidate()`
  is exposed for the secret-rotation path so a rotated tenant
  secret immediately drops cached tokens signed with the old key.
- **`Client::tenantToken()`**: thin HMAC-authed POST to
  `/v1/tenants/token`. Used only by `BrowserToken::mintFresh()`.
- **`Frontend/TypeaheadUI`**: localizes a new `gateway` block on
  `window.SEEKMODO_TYPEAHEAD` containing the gateway URL, tenant id,
  bearer token, expiry epoch, session id, and connector platform /
  version when minting succeeds. Falls back to the legacy WP REST
  shape transparently when the mint can't run (unpaired site,
  breaker open, mint disabled).
- **`assets/typeahead/seekmodo-typeahead.js`**:
  - **LRU cache** (32 entries, normalized `(vertical, max, q)` key)
    so a backspace + retype on the same prefix renders synchronously
    from memory, no network call. Cache size tunable via
    `seekmodo_typeahead_config` filter (`cache_size`).
  - **Gateway-direct fast path**: when a token is available, POSTs
    `${gateway.url}/v1/typeahead` with `Authorization: Bearer â€¦` +
    `X-Seekmodo-Tenant: â€¦` headers and a JSON body, bypassing
    WordPress entirely.
  - **Automatic fallback to WP REST** on any gateway-direct failure:
    network error, 401 (token expired between page render + this
    keystroke), 5xx, breaker open. Same logical request, second-best
    path, abort controller is reused â€" no double-fetch races.
  - **Token-staleness guard**: when the cached token has < 5 s of
    life left we drop straight to WP REST without even attempting
    the direct path; the WP REST endpoint will mint a fresh token in
    the same WordPress boot it would pay anyway.

### Filters

- **`seekmodo_typeahead_gateway_direct`** (bool, default `true`): a
  site can `__return_false` to force every typeahead call back to
  the WP REST endpoint without a connector downgrade. Useful for
  incident-response, A/B comparisons, or debugging a gateway-side
  issue without flipping the master enable toggle.
- **`seekmodo_typeahead_config`** (existing): now also lets a site
  override the new `cache_size` field.

### Compatibility

- **Backwards-compatible UX**: themes with their own typeahead
  integration that ride the existing `seekmodo_enqueue_typeahead`
  filter (Trek.ca's `inc/seekmodo-search.php` is the canonical
  example) keep that filter as the master kill-switch. The v0.5.0
  changes only land when the per-site enable toggle is on AND the
  filter doesn't refuse the enqueue.
- **No-op on unpaired sites**: a site that hasn't completed the
  Connect-to-Seekmodo round-trip won't have a tenant id or shared
  secret, so `BrowserToken::current()` returns null, the localised
  `gateway` block is absent, and the JS uses the WP REST endpoint â€"
  identical UX to v0.4.0.
- **Browser support**: gateway-direct path requires `fetch` +
  `AbortController` + `Map` (already required by the v0.4.0 base).
  IE11 / very old Safari paths bail before this code is reached.

### Observability

- Gateway access logs split connector-direct fetches from REST-
  proxied fetches via the connector's nginx access-log User-Agent
  string (browsers send their own UA on direct calls; that's the
  signal). The new `X-Seekmodo-Connector-{Platform,Version}`
  headers stamp every direct request so the gateway can chart
  per-platform / per-version typeahead volume the same way HMAC
  traffic is charted.
- The platform-side `searches_typeahead` display bucket (added in
  the SM-602 platform PR) is now populated by both the WP REST
  proxy and the direct path, so the merchant `/usage` tile shows
  the full picture without a per-source split.

### Limitations / known issues

- A tenant whose plan is downgraded mid-session keeps its existing
  cached browser token until the transient expires (â‰¤ 240 s). The
  next mint reflects the new plan. Operators who rotate the tenant
  secret should also call `BrowserToken::invalidate()` on the
  affected sites to force an immediate re-mint.
- The CORS preflight against `mcp.seekmodo.com` adds ~30â€"80 ms on
  the very first keystroke of a session (subsequent keystrokes
  reuse the preflight via the gateway's `Access-Control-Max-Age:
  600` cache). Net win: subsequent keystrokes are cache-or-network
  fast; only the first one pays the preflight cost.

## [0.4.0] - 2026-06-11

Drop-in front-end typeahead dropdown UI for the WordPress connector.
Driven by a request from POCO.com (a freshly-onboarded WooCommerce
tenant whose theme has no built-in autocomplete) to ship the same
search-as-you-type experience that the Zen Cart connector renders
for KIP â€" but without forcing every WP merchant to write a custom
theme integration the way trek.ca did.

### Added

- **`assets/typeahead/seekmodo-typeahead.js`**: vanilla-JS,
  no-dependency search suggestions dropdown. Auto-binds to common
  search inputs (`input[type="search"]`, `input[name="s"]`,
  `input[data-seekmodo-typeahead]`), debounces typing at 150ms,
  hits the existing `/wp-json/seekmodo/v1/typeahead` REST endpoint,
  and renders rows in two vertical-aware shapes:
  - **content**: thumbnail + title + post-type meta + excerpt.
  - **commerce**: thumbnail + title + price / sale-price / "Sale"
    badge / stock-status badge ("Out of stock" / "On backorder").
  Includes a `MutationObserver` re-scan so search inputs added by
  client-side routers (headless Gutenberg pages, AJAX cart drawers
  rendering fresh search inputs) get attached without a page reload.
  Stamps `data-seekmodo-id` / `data-seekmodo-pos` /
  `data-seekmodo-surface="typeahead"` on every row so the existing
  `seekmodo-beacon.js` click attribution picks them up â€" no second
  beacon, no separate endpoint.
- **`assets/typeahead/seekmodo-typeahead.css`**: scoped styling
  using CSS custom properties (`--seekmodo-ta-bg`, `--seekmodo-ta-fg`,
  `--seekmodo-ta-accent`, â€¦) so child themes can re-skin the
  dropdown without forking the file.
- **`src/Frontend/TypeaheadUI.php`**: enqueue + localisation glue.
  Self-gates on the per-site `seekmodo_wp_typeahead_ui_enabled`
  option (default OFF) plus the `seekmodo_enqueue_typeahead`
  filter, picks the storefront's primary vertical (commerce when
  WC is active and gateway-enabled, otherwise content), and passes
  the localised `window.SEEKMODO_TYPEAHEAD` config object the JS
  consumes.
- **Settings page**: new "Typeahead dropdown" section on
  `Settings â†' Seekmodo Search` exposing two checkboxes:
  - **Enable typeahead** â€" flip on per-site to render the dropdown.
  - **Show "Powered by Seekmodo"** â€" opt-in branding footer.
- **Filters**:
  - `seekmodo_enqueue_typeahead` (bool): theme can force-off
    site-wide regardless of the admin toggle. Trek.ca uses this to
    keep the connector UI dark while its custom
    `inc/seekmodo-search.php` integration stays the source of truth.
  - `seekmodo_typeahead_selectors` (string[]): override the CSS
    selectors the JS auto-attaches to. Useful for keeping the
    dropdown off non-search-bar inputs (e.g. the WC coupon-code
    field that happens to be `type="search"`).
  - `seekmodo_typeahead_config` (array): last-resort filter for
    mutating any localised field before emit.

### Why default OFF?

Trek.ca already ships a bespoke header typeahead in its theme
(`themes/trek-marketing/inc/seekmodo-search.php` +
`js/static.js`) that hits the same REST endpoint. Auto-enabling
the connector dropdown on top would render two overlapping popovers
under the same input. Default-OFF lets us roll v0.4.0 to every
paired tenant safely; partner sites with custom integrations stay
untouched, and POCO / future merchants opt in via the Settings
checkbox to get a turn-key typeahead with zero theme work.

### Operator runbook

- **POCO.com** (white-label, no theme integration):
  - `wp option update seekmodo_wp_typeahead_ui_enabled 1`
  - Leave `seekmodo_wp_show_branding` at `0` (default).
- **Trek.ca** (existing theme integration, branded):
  - Leave `seekmodo_wp_typeahead_ui_enabled` at `0` (default).
  - The theme's `inc/seekmodo-search.php` keeps forcing
    `seekmodo_show_branding` to true via its existing filter.
- **Future tenants**: flip the checkbox in
  `Settings â†' Seekmodo Search â†' Typeahead dropdown`.

### Compat / no-op cases

- Sites where the gateway is `mode=off` or the tenant isn't paired:
  the typeahead REST endpoint already falls back to a native
  `WP_Query` LIKE / `wc_get_product()` lookup, so the dropdown
  renders the right rows even without a working gateway link.
- IE11 / very old Safari (no `fetch()`): script bails silently;
  the form's submit-on-enter behaviour is unchanged.
- Sites without `wp_localize_script` (very old WP): enqueue still
  runs but `window.SEEKMODO_TYPEAHEAD` is undefined and the JS
  bails on the existence check.

## [0.3.4] - 2026-06-10

Editorial post-type ranking priority for the content-vertical
search swap. Triggered by a Trek Marketing complaint that
`https://trek.ca/?s=PPC` returned eight blog posts before the
high-intent service-detail pages (which live under `post_type=page`
on a `/services/...` URL hierarchy). Pages were being indexed and
returned by the gateway, but Typesense's textual-relevance score put
the blog feed first and the operator had no knob to express
editorial intent.

### Added

- **`Content/PostTypePriority`** (new): post-type priority
  re-ranker. After the gateway scores results by textual relevance,
  the IDs are bucket-sorted client-side so that hits whose post type
  appears earlier in the configured priority list come first; types
  not in the list come last. Within each tier the gateway's relative
  order is preserved -- we don't re-score textual relevance, just
  bucket-sort by post type. The post-type lookup is one indexed `IN
  (...)` SQL round-trip against `wp_posts.ID -> post_type` (negligible
  cost given the very next step is to hydrate the same IDs into
  WP_Post objects anyway).
- **`Config/Options::OPT_POST_TYPE_PRIORITY`**: new option
  `seekmodo_wp_post_type_priority`, stored as a CSV string (e.g.
  `"page,post"`) so it round-trips cleanly through `wp option update`
  and the Settings page without needing an array serializer. Empty
  default â€" no re-rank applies until the operator sets a value, so
  every existing 0.3.x install continues to flow gateway order
  through untouched.
- **`Admin/SettingsPage`**: new "Search ranking priority" section
  on Settings â†' Seekmodo Search, with a regular-text input that
  accepts the CSV directly. Help copy explains the bucket-sort
  semantics so operators don't expect a re-score.
- **`seekmodo_post_type_priority` filter**: overrides the option for
  site-specific mu-plugins that want to compute priority from URL
  prefix or category instead of post-type. Useful for sites where
  the editorial intent ("services should outrank blog") doesn't map
  cleanly to a post-type partition.

### Changed

- **`Content/SearchSwap::postsPreQuery()`**: now calls
  `PostTypePriority::applyOrder($ids)` between the gateway response
  and the hydration step. The hydration still uses
  `orderby=post__in` so the bucket-sorted ID sequence is what
  reaches the storefront's loop. No-op when the option is empty AND
  no filter adds anything (the same v0.3.3 path runs unchanged).

### Operator notes

- Trek.ca will set `page,post` so every page outranks every blog
  post for content-vertical searches. POCO Marine doesn't ship
  pages-as-services so they leave it empty.
- Re-rank only affects the content vertical (`SearchSwap`). The
  commerce vertical (`Commerce/SearchSwap`) is not touched -- product
  ranking is owned by the gateway's commerce-aware reranker, not by
  post-type buckets.

## [0.3.3] - 2026-06-10

Third hot-fix on the POCO Marine bring-up. v0.3.2 ported the WP
connector onto the canonical `install_token` + signed-JWT pairing
protocol, but the new REST callback route at
`/wp-json/seekmodo/v1/connect/callback` was registered behind
`Bootstrap::boot()`'s `is_admin()` gate. REST API requests are NOT
"admin" in WordPress's classification, so the server-to-server POST
from seekmodo.com hit the route table BEFORE the registration would
have run -- the route never existed for that request and WP returned
404. The admin-side Connect button worked (`is_admin() === true`
during admin-post.php), but the JWT callback silently failed and
pairing died at the last step.

POCO surfaced this on www.pocomarine.com immediately after the
v0.3.2 cherry-pick deployed.

### Fixed

- **`Bootstrap::boot()`**: `ConnectFlow::register()` was inside the
  `if (is_admin()) { ... }` block alongside the four admin-page
  components (`SettingsPage`, `PromoteFlow`, `HealthWidget`,
  `ModeBadge`). Lifted out of that block so the REST callback
  registration always runs. The other four stay admin-gated since
  they only render in the wp-admin UI.

  ```diff
   WporgUpdateClient::register();
  +
  +ConnectFlow::register();
  +
   if (is_admin()) {
       SettingsPage::register();
  -    ConnectFlow::register();
       PromoteFlow::register();
       HealthWidget::register();
       ModeBadge::register();
   }
  ```

  POCO repo's vendored plugin tree got the same fix on master and
  cherry-picked onto production so www.pocomarine.com gets it on
  the next webhook deploy.

### Notes

- The v0.3.2 zip was built from the `cc8bbfc` commit (before this
  Bootstrap fix landed in subsequent feature work on main), so the
  publicly-downloadable zip on seekmodo.com had the same bug. v0.3.3
  rebuilds the zip from current main HEAD, which carries the fix
  alongside the Sprint 11 PR 1/2/3 surfaces (Gutenberg blocks,
  Elementor widgets, WPBakery shortcodes) that landed during the
  POCO bring-up window. No tenants paired against v0.3.2 on the
  manifest URL since this fix landed within minutes; v0.3.3 is the
  effective successor.

## [0.3.2] - 2026-06-10

Second hot-fix on the POCO Marine bring-up. v0.3.1 fixed
`wp_safe_redirect()` silently dropping off-site redirects to the WP
admin homepage, but the underlying pairing protocol was still wrong
-- the WP connector was sending legacy `nonce` / `return_to` query
params that seekmodo.com's `/connect` page rejects with
"This link is incomplete". The redirect-back side of that protocol
was aspirational and never built on the seekmodo.com side.

v0.3.2 ports the WordPress connector onto the canonical
`install_token` + signed-JWT protocol that Seekmodo for Zen Cart has
used since v1.0.x and that seekmodo.com's
`/api/connect/complete` route actually implements.

### Added

- **`src/Connect/Pairing.php`** -- EdDSA pairing-JWT verifier.
  Mirrors the Zen Cart connector's `Pairing::verify_jwt()`, adapted
  for WordPress (uses `wp_remote_get` for JWKS fetch, `wp_transient`
  for the 5-min JWKS cache). Hand-rolled rather than pulling in
  `firebase/php-jwt` because the shape is small (one alg, one curve,
  three b64url decodes) and we don't otherwise need a Composer JWT
  dep on a WP-managed install.
- **`src/Admin/ConnectFlow::registerCallback()`** + the public REST
  endpoint at `wp-json/seekmodo/v1/connect/callback`. seekmodo.com
  POSTs `{token, install_token}` to that URL with the
  `X-Seekmodo-Install-Token: <t>` header. Verifies the install token
  against the persisted value, verifies the JWT signature against
  `https://seekmodo.com/.well-known/jwks.json`, validates the
  standard claims (iss=seekmodo.com, aud=seekmodo-plugin, exp,
  install_token cross-check), and on success persists the pair +
  the `mcp_url` claim and burns the install_token.
- **`Options::OPT_INSTALL_TOKEN` / `OPT_INSTALL_TOKEN_EXP`** --
  one-shot pairing handshake bookkeeping. Stored as wp_options (not
  transients) so the values survive an object-cache flush between
  the redirect and the callback POST. Both rows use autoload=false.

### Changed

- **`Admin\ConnectFlow::initiate()`** now mints a 32-hex
  `install_token` (10-min TTL) and sends
  `?install_token=<t>&callback=<rest-url>&platform=wordpress&plugin_version=<v>`
  to seekmodo.com/connect, instead of the legacy
  `?nonce=<n>&return_to=<u>` shape. Keeps the v0.3.1
  `allowed_redirect_hosts` filter -- still required so
  `wp_safe_redirect()` doesn't drop the off-site target.
- **`Admin\ConnectFlow::handleCallback()`** rewritten to verify the
  signed-JWT body. Replaces both the browser-GET landing handler
  (with `?seekmodo_token=` redemption) and the
  `Routes::NAMESPACE_NAME . '/connect/callback'` REST handler that
  expected `{state, tenant_id, shared_secret}` plain-text fields --
  both shapes were aspirational and never reached.

### Removed

- **`Gateway\Client::verifyClaimToken()`** and the matching
  `wp seekmodo connect --token=<...>` CLI flag. They round-tripped
  through a `/v1/connect/verify` gateway endpoint that doesn't
  exist. The CLI pair still works via `--tenant=` `--secret=` for
  headless installs.
- **`Admin\ConnectFlow::handleCallback()`** as a browser-GET handler
  -- the new POST handler at the same name supersedes it.

### Notes

- **POCO Marine impact**: dev.pocomarine.com's plugin tree was
  patched in-place via `numinix/pocomarine` master commit
  cherry-picked onto `production` so www.pocomarine.com gets the
  fix on the next webhook deploy. www.pocomarine.com was the
  reproducer for "Click Connect, end up on 'This link is incomplete'".
- **Marketing-site allow-list**: the new POST endpoint lives at
  `wp-json/seekmodo/v1/connect/callback` on the merchant's
  domain -- the `connect_callback_hosts` Postgres table on the
  marketing-site DB MUST contain the merchant's host before pairing
  succeeds. POCO's `pocomarine.com`, `www.pocomarine.com`,
  `dev.pocomarine.com`, and `www.dev.pocomarine.com` were inserted
  as part of this release. New tenants picked up by the
  marketing-site provisioner get auto-allowlisted; legacy
  gateway-side `tenant-add.php` provisions still need a manual
  insert into `connect_callback_hosts`.

## [0.3.1] - 2026-06-10

Hot-fix immediately on the heels of the v0.3.0 POCO Marine bring-up.
The browser-based "Connect to Seekmodo" flow was silently redirecting
operators back to the WP admin homepage instead of seekmodo.com â€"
pairing through the UI was impossible. The CLI flow (`wp seekmodo
connect --tenant=... --secret=...`) was unaffected, but most
merchants don't have wp-cli access.

### Fixed

- **`Admin\ConnectFlow::initiate()`**: previously called
  `wp_safe_redirect('https://seekmodo.com/connect?...')` with no
  registered allow-list for the seekmodo.com host. WP's redirect
  validator silently rewrites unknown hosts to the site root, so the
  operator clicking "Connect" landed on `/wp-admin/` instead of the
  pairing page. Now wraps the redirect call in a one-shot
  `allowed_redirect_hosts` filter that adds the connect host
  (default: seekmodo.com; overridable via the existing
  `seekmodo_connect_base_url` filter) for THIS request only, then
  removes the filter after the redirect so we don't widen the
  allow-list for any other code path on the same request. Surfaces
  no behaviour change for the same-origin redirects in
  `redirectToSettings()` and `PromoteFlow::redirectToSettings()`,
  which were already same-origin and worked fine.

  See `src/Admin/ConnectFlow.php`. The fix also lands in the
  POCO repo's vendored copy (PR-302 commit `146c5a45`) so
  dev.pocomarine.com gets it on the next webhook deploy without
  waiting for the v0.3.1 zip to clear the marketing manifest.

### Notes for Wordfence-running sites

A few merchants hit the same "Connect lands on wp-admin" symptom
even after the v0.3.1 fix because Wordfence's WAF intercepts the
return GET (`?seekmodo_token=...&state=...`). If pairing still
fails after upgrading:

1. Wordfence â†' Firewall â†' Allowlisted URLs â†' add
   `wp-admin/admin-post.php?action=seekmodo_wp_connect_callback`
2. Wordfence â†' Firewall â†' Brute Force Protection â†' confirm
   "Lock out users after how many forgotten password attempts"
   isn't catching the connect bounce (rare; only matters on
   sites that customised the lockout window).

The runbook (`docs/runbooks/pocomarine.md`) has the same notes
under Phase 5.2 troubleshooting.

## [0.3.0] - 2026-06-10

This release closes the WooCommerce vertical's blocking schema-drift bug
(commerce upserts were 400'ing on the gateway end) and ships the
self-service dev â†' production canonical-host promotion path so merchants
can flip their tenants without an operator round-trip. POCO Marine is
the first tenant going live against the new pipeline.

### Changed

- **Commerce vertical doc shape realigned to the gateway Typesense
  schema.** `Commerce/ProductFields::buildDoc()` now emits
  gateway-canonical field names: `name`, `url`, `in_stock` (bool),
  `category_id[]` (int term-IDs), `category_breadcrumbs[]` (human
  hierarchy strings), `image_url`, `description` (combined
  excerpt + content_plain, HTML-stripped, 4 KB cap),
  `last_indexed_at`, plus the new optional `on_sale` / `sale_price`
  / `currency` fields the matching `add-woocommerce-commerce-fields.php`
  migration adds to existing commerce collections. Drops every
  pre-v0.3.0 WC-shaped name (`title`, `permalink`,
  `stock_status` string, `regular_price`, `excerpt`,
  `content_plain`, `attributes`, `variations[]`, `categories`,
  `post_type`, `extra`) â€" the gateway's strict-schema Typesense
  was rejecting every upsert against those names. Same drift class
  as the content-vertical fix in v0.1.1; nobody had shipped
  against the commerce vertical yet so the breakage was hidden.
- **Commerce schema version split from the content schema
  version.** New `Options::OPT_COMMERCE_SCHEMA_VERSION`
  (`commerce_schema_version` in `wp_options`), bumped to `1` for
  this realignment. `Commerce/Indexer::tick()` now compares
  against `Options::COMMERCE_SCHEMA_VERSION` and force-promotes
  the next tick to a full reindex on bump â€" mirroring the
  content-side `Content/Indexer::tick()` heuristic. Splitting the
  two avoids dragging the content vertical through an unnecessary
  full reindex every time only the commerce side moves. See
  `docs/COMMERCE_VERTICAL.md` Â§ *Schema-version auto-promote*.

### Added

- **Multi-vertical bootstrap gate.** New
  `Options::OPT_ENABLED_VERTICALS` (`seekmodo_wp_enabled_verticals`
  in `wp_options`) tracks which verticals the gateway has provisioned
  for this tenant. `Bootstrap::boot()` reads
  `Options::enabledVerticals()` and only registers `Content*` /
  `Commerce*` sub-modules for verticals the gateway has on file â€"
  prevents a commerce-only tenant (e.g. POCO at first ramp) from
  pushing post-shaped docs at a products Typesense collection and
  triggering schema-mismatch 4xx every cron tick.
  `RemoteConfig::applyOverrides()` mirrors the new
  `tenant.snapshot.verticals` field (array or comma-string) into the
  option on every 5-min pull. Stale-snapshot fallback is the
  all-enabled set, which preserves pre-v0.3.0 behavior for installs
  whose first config-pull hasn't completed and for gateways that
  pre-date the surfacing change. See `Bootstrap::boot()` and
  `Config/Options::enabledVerticals()`. Companion gateway change:
  `TenantSnapshotTool::execute()` now derives `vertical` /
  `verticals` from `numinix_mcp_tenants.vertical` and includes them
  in the snapshot payload.
- **Self-service "Promote dev â†' production" card on the Seekmodo
  Search settings page.** Renders when
  `DomainLock::looksLikeNonProd($current_host)` is true and the
  tenant's `locked_domain` matches the current host (typical
  state for a tenant that paired its dev site first). The card
  lists every prod-looking entry from `tenant.snapshot`'s
  `observed_domains` plus a free-text override box (for apex
  migrations / rebrands) and POSTs the chosen host through
  `Gateway\Client::promoteCanonicalDomain()` â†' the gateway's new
  `tenant.domain.promote` MCP tool. The tool atomically swaps
  `locked_domain` and writes a `domain_lock.changed` audit row;
  within 5 min the dev plugin's next `tenant.snapshot` pull picks
  up the new lock, `DomainLock::matches()` returns false, and
  every gateway-talking entry point goes silent. Closes the
  operator round-trip the v0.2.x flow required and is the
  building block for the POCO go-live cutover (Phase 6 of the
  POCO launch plan).
- **`Admin\PromoteFlow`** â€" orchestrates the card render
  decision (`shouldOffer()`), filters `observed_domains` to
  prod-looking candidates (`productionCandidates()`), validates
  + sanitises the operator's input (`sanitizeHost()`), and
  performs the gateway round-trip (`handle()`). Tested in
  `tests/Unit/PromoteFlowTest.php` covering the
  pairing / non-prod / lock-matches gate logic and the
  `productionCandidates()` filter, dedup, and snapshot-shape
  defensive cases.
- **`Gateway\Client::promoteCanonicalDomain()`** â€" thin HTTP helper
  POSTing `{ new_host, previous_host, platform, connector_version }`
  to `/v1/tenant/domain/promote`. Reuses the same HMAC envelope
  every other gateway call uses.
- **Admin-app mirror in `tenant-settings-form.tsx`.** Operator-side
  "Promote dev â†' production" hint banner with a one-click action
  that pre-fills the locked-domain field from the most-recently-seen
  prod-looking entry in `observed_domains`. Operator clicks
  *Save changes* to apply via the existing `tenant.config.set`
  path. Mirrors the connector card's heuristic via a
  `NON_PROD_LEFT_LABELS` set kept lockstep with the gateway's
  `Store::isProdLookingHost()` and the connector's
  `DomainLock::looksLikeNonProd()`.
- **Gateway `tenant.domain.promote` MCP tool**
  (`services/mcp-gateway/src/Tools/TenantDomainPromoteTool.php`,
  registered in `ToolCatalog::buildStandard()`). Per-tenant HMAC
  rather than admin-only â€" the merchant-facing flow needs to call
  it without going through admin.seekmodo.com â€" but the tighter
  input gating (host must already be in `observed_domains`,
  leftmost label cannot match the non-prod heuristic) plus a
  dedicated audit-log row compensate for the lack of operator
  auth. Operator-driven apex migrations still go through
  `tenant.config.set` (admin-only). Whitelisted in
  `AdminOnlyToolDispatchTest::mustBeOpen` so a future paranoid PR
  can't silently flip it admin-only.
- **`Commerce/Typeahead`** updated to read the new
  gateway-aligned fields (`name`, `url`, `in_stock`, `image_url`)
  while still emitting the legacy theme-friendly names
  (`title` / `permalink` / `stock_status` enum) on the way out,
  so storefront themes keep working without a coordinated push.
- **Tests:** rewrote `tests/Unit/ProductFieldsTest.php` to assert
  the gateway-canonical doc shape (and that the pre-v0.3.0 names
  are gone). Added `tests/Unit/PromoteFlowTest.php` for the
  promote-card eligibility + candidate-filter logic. Extended
  `tests/wp-shims.php` with a `seekmodo_test_home_url_host`
  global so tests can stub `home_url()` cleanly. Added
  `services/mcp-gateway/tests/Tools/TenantDomainPromoteToolTest.php`
  pinning the new tool's surface descriptors and input-validation
  paths (missing / empty / garbage host â†'
  `invalid_host`; non-prod left labels â†' `host_looks_nonprod`).

### Migration notes

- Operators on existing commerce-vertical tenants must run
  `php services/mcp-gateway/ops/add-woocommerce-commerce-fields.php`
  before the first v0.3.0 connector points at them, so the
  Typesense collection picks up `on_sale` / `sale_price` /
  `currency`. Fresh tenants provisioned via `tenant-add.php`
  already get the fields baked in.
- `OPT_COMMERCE_SCHEMA_VERSION` defaults to `0` on existing
  v0.2.x sites; the first `Commerce/Indexer::tick()` after
  upgrade promotes itself to a full reindex automatically. No
  operator action required on the connector side.

## [0.2.0] - 2026-06-10

### Added

- **Signature-mismatch self-recovery** (`SignatureMismatchTracker`).
  When the gateway refuses 3+ HMAC requests with
  `reason: signature_mismatch` inside a 5-minute sliding window, the
  WP admin surfaces a yellow notice on every page pointing at the
  Re-pair button. The notice clears itself the moment the next
  gateway call returns 2xx. Closes the gap that
  `docs/follow-ups/connector-signature-mismatch-repair.md` (in
  `numinix/seekmodo`) flagged: the 10-minute previous-secret grace
  on the gateway covers proactive rotations, but a connector whose
  snapshot cache was warmer than expected -- or a merchant who
  rotated >10 minutes before re-pairing -- would 401 indefinitely
  without an obvious recovery signal. Threshold + window are
  filterable via `seekmodo_sigmismatch_threshold` and
  `seekmodo_sigmismatch_window_seconds` so an operator can tune
  per-tenant without forking the plugin. Audit P-G, 2026-06-10.

## [0.1.5] - 2026-06-08

### Changed

- **Release signatures now use a stable, kid-namespaced ed25519 key**
  instead of the placeholder `dev-ephemeral` label. Active kid is
  `seekmodo-2026-06`; the corresponding public-key JWK ships in
  `assets/release-signing-keys/seekmodo-2026-06.pub`. The build
  pipeline (`tools/build_release.py`) resolves the private key by
  kid via `~/.numinix/release-signing-<kid>.key`, falls back to the
  legacy unnamed `release-signing.key`, and emits
  `sig_kid: seekmodo-2026-06`, `signed_with: file:<path>` in the
  manifest entry. Existing v0.1.4 installs auto-upgrade transparently
  (the WP update path doesn't currently gate on signatures); from
  v0.1.5 onward, the plugin tree ships the trust root needed for a
  future in-plugin verifier to enforce signatures without out-of-band
  key distribution. See `docs/SIGNING_KEYS.md` (in
  `numinix/seekmodo`) for the key inventory and rotation runbook.
- **Manifest signatures emitted with base64url-nopad encoding**
  (matching the JWS / JWK convention) instead of standard base64.
  The Zen Cart in-plugin verifier already tolerates both alphabets,
  so this is a cosmetic alignment; the WP `WporgUpdateClient` does
  not currently verify signatures.

## [0.1.4] - 2026-06-08

### Added

- **WordPress.org Plugin Directory submission readiness** (slug:
  `seekmodo`). The plugin is now wired to live in two distribution
  channels simultaneously: discoverable via WP-admin â†' Plugins â†' Add
  New â†' search ("seekmodo"), and updated thereafter from
  seekmodo.com. New plugin header `Update URI:` field (introduced in
  WP 5.8) is set to `https://seekmodo.com/plugins/seekmodo`; w.org's
  update backend treats that as "yield â€" someone else owns this
  plugin's update channel" and stops serving updates, while WP-admin
  fires `update_plugins_seekmodo.com` as a filter for the version
  check. See `docs/WORDPRESS_ORG_LISTING.md` for the submission
  workflow and the post-approval SVN sync.
- **`Numinix\SeekmodoWordPress\Updates\WporgUpdateClient`** â€" the
  filter hook listener. Wires both `update_plugins_seekmodo.com`
  (returns the update payload for the WP-admin Updates page) and
  `plugins_api â†' plugin_information` (drives the "View details"
  modal with our description, changelog, banners, and icons). Reads
  the update info from `https://seekmodo.com/api/plugins/seekmodo`
  (cached 12 h on success, 5 min on failure to avoid hammering
  marketing-site during an outage). Endpoint URL is filterable via
  `seekmodo_wporg_update_endpoint` for staging / private rollouts.
- **`docs/RELEASING.md`** â€" full operator-local release ritual (tag â†'
  build â†' `--auto-pr` â†' merge â†' seek-api01 webhook redeploys
  marketing-site â†' live download). Documents the prerequisites
  (signing key, `SEEKMODO_PUBLISH_TOKEN`), the manual-commit fallback
  for when `--auto-pr` is unavailable, and the emergency GHA
  `workflow_dispatch` path. (Shipped in 0.1.3 retroactively but the
  CHANGELOG only flagged it in `[Unreleased]`.)

### Changed

- Plugin header `Plugin Name` shortened from "Seekmodo Search for
  WordPress" to "Seekmodo Search" so the title in WP-admin and the
  w.org Plugin Directory matches our brand surface across other
  platforms (Zen Cart connector, AKS connector). The "for
  WordPress" suffix is what w.org's plugin-review guidelines
  explicitly discourage as redundant when the plugin is in the WP
  directory.
- Plugin header `Tested up to: 6.6` added so the WP-admin
  compatibility column and the w.org Plugin Directory listing pick
  up the latest tested WordPress release.
- Plugin header `Description` rewritten for w.org's plugin-card
  preview (which truncates at ~150 chars); leads with the value
  prop, then the Seekmodo dependency disclosure + free-trial note,
  matching the Mailchimp / Algolia / Akismet pattern. Reviewer-facing
  hint, basically.

### Fixed

- **`tools/build_release.py --auto-pr` now writes a manifest entry
  with the same schema as the live `zen_cart` entry** (`sha256`,
  `sig`, `signed_at`, `sig_kid`, `signed_with`, `released_at`,
  `release_notes_url`) instead of the orphan
  `sha256_url`/`signature_url`/`released_at_utc` shape it produced
  before. The earlier shape would have broken any in-plugin
  `UpdateClient` consumer (the Zen Cart connector ships one reading
  `platforms.<vertical>.{sha256, sig, ...}`); the new
  `WporgUpdateClient` in this release would have hit the same
  problem. Replaces a `subprocess.check_output(["date", ...])` call
  that wouldn't work on Windows with a portable
  `datetime.now(timezone.utc)`.

## [0.1.3] - 2026-06-08

### Added

- **Per-tenant "Powered by Seekmodo" branding toggle.** New
  `Options::OPT_SHOW_BRANDING` (`seekmodo_wp_show_branding`) option,
  pulled down from the gateway's
  `numinix_mcp_tenant_config.show_seekmodo_branding` column on every
  5-min `tenant.snapshot` cadence. Defaults to `false` for every tenant
  â€" operators flip it on per-tenant from the admin.seekmodo.com tenant
  settings page for partner / specific-deal sites.
- `numinix_seekmodo_show_branding()` public helper. Wraps the option
  read plus a `seekmodo_show_branding` filter so themes can override
  the final answer (handy during partner-site rollouts before the
  operator-side toggle is live). Returns `false` on an unpaired
  connector regardless of the option value.
- `numinix_seekmodo_brand_url()` public helper returning the canonical
  badge target URL (`https://seekmodo.com/`). Filterable via
  `seekmodo_brand_url` for co-branded landing pages. Themes that render
  the badge should link to this with `target="_blank"
  rel="noopener noreferrer"`.

## [0.1.2] - 2026-06-08

### Fixed

- **AutoPromoter substate FSM was never actually advancing.** Two
  structural bugs combined to make the promised "set MODE=active +
  `auto_promote=true` and we'll bake into enforce automatically" path
  silently no-op:
  1. `Mode\AutoPromoter::evaluateFsm()` guarded its
     promote/demote branches on `ModeFsm::current() === MODE_SHADOW`
     / `MODE_ENFORCE`, but those represent the *operator-set headline*
     mode, not the auto-promoter's internal substate. The substate
     transition `active+shadow â†' active+enforce` (and the reverse on
     demote) was therefore unreachable. Rewritten to gate on
     `ModeFsm::current() === MODE_ACTIVE` and read the actual substate
     from the `seekmodo_wp_autopromote_state` option.
  2. Both `Content/SearchSwap::postsPreQuery()` and
     `Commerce/SearchSwap::onProductQuery()` consulted
     `ModeFsm::current()` directly when deciding whether to fall back
     to native results, so even if the FSM *had* been advancing the
     substate, the swap-points wouldn't have honored it. Both now
     consult `AutoPromoter::effectiveMode()`, which projects the
     headline mode through the substate.
- `AutoPromoter::effectiveMode()` now collapses MODE=active to a
  hard `enforce` when `auto_promote=false`, matching the AKS / Zen
  Cart connector behavior: opting out of the safety hop means the
  operator wants enforce, period.

### Added

- `AutoPromoter::currentSubstate()` public helper so the
  ModeBadge, REST `/status` route, and CLI `health` / `diagnose`
  commands can introspect the FSM consistently.
- Six new `AutoPromoterTest` cases covering the substate FSM:
  fresh-activation default, opted-out collapse, healthy-promote
  (the "Auto mode out of the box" flow), insufficient-sample no-op,
  manual-shadow no-promote, and enforce-demote-on-breach.

## [0.1.1] - 2026-06-08

### Changed

- **Content indexer doc shape aligned with the gateway's Typesense
  `content` collection schema.** `Content/Indexer::buildDoc()` now
  emits gateway-native field names instead of the WP-shaped names
  that the v1 connector was shipping; the gateway is strict-schema
  via Typesense and was rejecting every document on the first
  `index.upsert`. Renames:
  - `content_plain` â†' `body`
  - `permalink` â†' `url`
  - `date_published_unix` â†' `published_at` (and now **required**, set
    to `now()` as a last-ditch fallback so the default-sort field is
    never empty)
  - `author_id` (int) â†' `author` (string, display name)
  - `taxonomies` (nested map) â†' `tags` (flat string[], dedup'd across
    taxonomies; `post_format` and `nav_menu` are filtered out)
  - `id` is now serialised as string (Typesense requires `id` to be
    a string)
  - The rich-preview fields `excerpt`, `thumbnail_url`, `post_type`,
    `is_sticky` are kept (matching their gateway schema names + types
    after the companion seekmodo-monorepo PR that adds them to
    `tenant-add.php` and `add-content-rich-fields.php`).
- `Options::SCHEMA_VERSION` bumped 1 â†' 2 so an upgraded site's next
  `seekmodo_wp_index_tick` is auto-promoted to a full reindex (per
  `Content/Indexer::tick()` semantics). No operator action needed.

### Added

- Sprint 0: repo scaffolding, plugin bootstrap, PSR-4 autoloader,
  `Numinix\SeekmodoWordPress\Bootstrap` skeleton, composer + PHPUnit harness,
  `tools/build_release.py` operator-local pipeline, emergency-fallback
  `.github/workflows/release.yml`.
- Sprint 1a: `Gateway\Client` (HMAC-signed POST + 250 ms timeout +
  jittered retry + breaker integration), `Gateway\CircuitBreaker` (5-in-30s,
  60s open, half-open probe), `Mode\ModeFsm` (off/shadow/active/enforce),
  `Admin\ConnectFlow` (claim-token round-trip with `seekmodo.com/connect`,
  one-shot nonce, REST fallback), `Admin\SettingsPage` (pair status,
  health, post-type allowlist), `Cli\Commands::connect|index|health|
  refresh-config|shadow-report|cutover-readiness|diagnose`.
- Sprint 4: Events + first signed release. `Events\EventsQueue` builds
  out the custom `{$prefix}seekmodo_events` table (id, type, object_id,
  keyword, position, surface, bot_verdict, payload, created_at) with
  indexes on (created_at) and (type, created_at). `EventsQueue::flush()`
  drains 200 rows per tick, forwards to `Client::sendEvents()`, and
  deletes the rows on a 2xx ack. 7d retention with opportunistic pruning
  on each tick. `Events\ClickBeacon` wires the REST `POST /click`
  endpoint plus the auto-enqueued `assets/typeahead/seekmodo-beacon.js`
  (navigator.sendBeacon with a fetch keepalive fallback). `Admin\HealthWidget`
  prints a dashboard one-screen health summary (mode, effective mode,
  breaker, p50/p95/5xx/fallback gauges, indexer cursor, events queue
  depth, last snapshot pull). `Admin\ModeBadge` adds a colored admin-bar
  badge for at-a-glance mode + breaker state. `Rest\Routes::health`
  provides an admin-only GET endpoint mirroring the dashboard summary
  as machine-readable JSON. `Gateway\Client::sendEvents()` bulk-forward
  helper. `Cron\EventsFlushCron::tick()` now calls `flush()` (was a stub).
- Sprint 3: Commerce vertical. `Commerce\SearchSwap` hooks
  `woocommerce_product_query` + `posts_search` with active-filter
  capture (price range, attribute filters via `?filter_*` / `?pa_*`,
  rating filter). `Commerce\Indexer` walks products in 500-doc batches
  with delta hooks on `woocommerce_update_product`, `woocommerce_delete_product`,
  variation stock + product stock, `woocommerce_update_product_variation`,
  and `transition_post_status` for product / product_variation post types.
  `Commerce\ProductFields::buildDoc()` projects WC products to the
  commerce doc shape (sku, price/regular/sale/on_sale, stock_status,
  categories, tags, brand auto-detected from WC Brands / Perfect Brands /
  YITH Brand, attributes, variations[], currency).
  `Commerce\Typeahead` shapes commerce rows for product cards (image,
  price, sale_price, on_sale, stock_status) and provides a native fallback
  via `wc_get_product()`. `Content\Typeahead` exposes
  `seekmodo_typeahead_shape_row` + `seekmodo_typeahead_native_fallback`
  filters so commerce can hook in.
- Sprint 2: `Mode\AutoPromoter` (rolling p95 / 5xx / fallback gauges over
  a 5-min window, thresholds 5xx<1% / p95<300ms / fallback<2%; demote
  hysteresis at 2x the promote thresholds; substate-on-top-of-active so
  the operator's headline MODE setting is preserved). `Mode\ShadowDiffLogger`
  (JSON-lines log under `wp-content/uploads/seekmodo-shadow.log` with daily
  rotation + 14d retention + Jaccard / top1 / top3 / p95 aggregation for
  `wp seekmodo shadow-report --window=`). `Config\RemoteConfig::pull()`
  consumes the gateway's `tenant.snapshot` and patches local options
  (mode, auto_promote, storefront_host, schema_version, breaker
  thresholds) on a 5-min cron cadence with a transient cache.
  `Config\DomainLock` enforces the canonical-host gate with a
  `seekmodo_disable_storefront_host_lock` escape hatch for CDN
  blue/green deploys. `wp seekmodo shadow-report` upgraded to use the
  aggregator instead of raw tail.
- Sprint 1b: `Content\SearchSwap` (`pre_get_posts` + `posts_pre_query`
  with shadow-mode diff emission), `Content\Indexer` (full + delta
  walker with `_seekmodo_dirty` postmeta + schema-version auto-promote),
  `Content\Typeahead` (REST endpoint with native fallback shape parity +
  `[seekmodo_search]` shortcode), `Content\PostTypeRegistry` (auto-detect
  public CPTs, filter-overridable).
- Sprint 5: full `wp seekmodo cutover-readiness` gate (paired,
  domain lock match, breaker closed, p95 / 5xx / fallback within
  promote thresholds, minimum sample volume, shadow-diff jaccard
  signal across `--window=`). WordPress.org submission package:
  `readme.txt` with Screenshots + Upgrade Notice sections,
  screenshot placeholders under `assets/admin/`, hand-built i18n
  `.pot` at `languages/seekmodo-wordpress.pot` covering the
  Settings page, HealthWidget, ConnectFlow notices, and cron
  recurrence labels. Docs full pass: `TROUBLESHOOTING.md`
  one-liners now reference `wp seekmodo shadow-report --tail` and
  `wp seekmodo cutover-readiness` instead of the legacy
  `wp seekmodo trace`.

## [0.1.0] - Unreleased

Initial pre-release. See `README.md` for the sprint matrix.
