=== Keva for WordPress ===
Contributors: keva
Tags: ai, support, page-builder, elementor, gutenberg, wpbakery, divi, beaver, bricks, oxygen, brizy, cwicly
Requires at least: 6.0
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 1.1.79
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

The single Keva plugin for WordPress — connects your site to Keva for AI-powered, builder-aware content management. Replaces the separate Keva Connector + Keva Bridge plugins.

== Description ==

Keva for WordPress is the official integration plugin for [Keva](https://keva.support), the AI-powered support agent platform. Install it once, paste a token into the Keva dashboard, and Keva can read and update your site's content — including content stored by page builders that the standard WordPress REST API cannot reach.

**Replaces two older plugins (Keva Connector + Keva Bridge) with a single install.** Existing customers can install this plugin alongside the legacy plugins for a smooth transition — see "Upgrading" below.

**What Keva can do once connected:**

* Read and edit Gutenberg, Elementor (free + Pro), WPBakery (Visual Composer), Divi 4, Beaver Builder, Bricks, Oxygen, Brizy, Cwicly, and Classic posts/pages — builder-aware, no silent no-ops
* Find / replace text on any page regardless of builder
* Append, update, delete individual blocks / widgets / elements by ID
* Add new sections, duplicate sections, reorder columns (Gutenberg + Elementor)
* Swap in-content images (find images on a page, replace by ID)
* Update site-wide brand colors and font families (block themes via theme.json; classic themes return a graceful "not supported")
* Add / update / remove pricing tiers, FAQ items, and testimonials with builder-specific shape construction
* Designate AI-editable regions on any page via the `[keva-region]` shortcode or `keva/region` Gutenberg block — gives Keva a stable, builder-agnostic update path on any page builder

**Without this plugin, Keva falls back to the standard WordPress REST API**, which writes successfully on classic posts but silently fails on every page builder's actual storage layer (post_meta, serialized JSON, custom shortcode trees). With this plugin installed, all of the above just works.

== Continuous Integration ==

Every change to this plugin runs through automated tests on GitHub Actions. Three tiers gate progressively:

* **Tier 1** — Gutenberg + Elementor integration tests on every pull request (~5 min)
* **Tier 2** — full Layer 2 + Playwright e2e suite on every push to main (~10 min)
* **Tier 3** — weekly full-suite cron Monday 06:00 UTC; expands to a premium-builder matrix (Elementor Pro, Divi, Bricks, Beaver Pro, WPBakery) as those builders ship

Tier 2 build status badge:
https://github.com/jeewandaniel/keva/actions/workflows/test-wp-tier2.yml/badge.svg

Latest test runs:
https://github.com/jeewandaniel/keva/actions/workflows/test-wp-tier2.yml

The full test framework + result artifacts live in the public monorepo at https://github.com/jeewandaniel/keva — every release is published only after Tier 2 passes green.

== Installation ==

1. Upload the `keva` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. In wp-admin, open **Keva → Status**. It shows your Connector token (content) and Bridge token (full connection), each with a one-click copy and a Regenerate button.
4. Paste the **Bridge token**, with your site URL, when adding WordPress under Keva → Integrations → Platform Connectors — that is the full connection. (The **Connector token** is a content-only alternative for Keva → Integrations → WordPress Sites.)

The freshly-generated tokens appear on the Keva → Status page right after activation. For security they are stored hashed — if you miss the one-time display, click Regenerate to mint a new one (then re-paste it into Keva).

== Upgrading from Keva Connector / Keva Bridge ==

If you already have the legacy `keva-connector` and/or `keva-bridge` plugins installed:

1. Install + activate Keva for WordPress (this plugin) alongside the legacy plugins. They coexist safely — when the legacy plugins are active, this plugin defers REST registration to them so nothing is double-registered.
2. Verify the unified plugin is healthy. The token hashes from your legacy plugins are preserved as-is, so the same tokens you already pasted into the Keva dashboard continue to work.
3. Deactivate the `keva-connector` plugin. The unified plugin takes over `/wp-json/keva-connector/v1/*` automatically.
4. Deactivate the `keva-bridge` plugin. The unified plugin takes over `/wp-json/keva/v1/*` automatically.
5. Optionally remove the legacy plugin folders from `/wp-content/plugins/`.

== Frequently Asked Questions ==

= Do I need this plugin? =

If you want Keva to manage content on a WordPress site, yes — especially if the site uses any page builder. Without this plugin, Keva can only edit basic post metadata and classic post content; everything stored by Elementor / Divi / WPBakery / Beaver / Bricks / Oxygen / Brizy / Cwicly silently fails to render.

= Where do I find my Bridge token? =

In wp-admin, open **Keva → Status**. Both the Connector and Bridge tokens are shown there, with one-click copy. The plaintext is displayed once right after activation; if you miss it, click **Regenerate** to mint a fresh token (then re-paste it into the Keva dashboard). Tokens are stored hashed, so an existing one can't be shown again.

WP-CLI still works if you prefer it: `wp eval 'echo get_transient( "keva_bridge_new_token" );'`

= Is it safe? =

Yes. Tokens are stored as bcrypt hashes (same as WordPress passwords). HTTPS is enforced in production. Rate limiting (200 requests / 5 minutes per IP, configurable). All input is sanitized; all output is escaped. No `eval()`, no remote file inclusion, no untrusted deserialization.

= Does Keva need both REST namespaces? =

Yes — the Keva dashboard's WordPress connector code routes "vanilla post CRUD" (list posts, list comments, etc.) to `/wp-json/keva-connector/v1/*` and "builder-aware page operations" (read structure, replace text, append block, regions) to `/wp-json/keva/v1/*`. This unified plugin serves both namespaces from a single install.

= What if I just want the vanilla REST features, not the builder-aware ones? =

The plugin serves both namespaces unconditionally. The builder-aware endpoints are dormant until your Keva dashboard actually calls them — they have no runtime cost when unused.

= What page builders are NOT supported? =

* **Divi 5 / DiviBlocks** — explicitly detected and refused (Divi's new block format isn't publicly documented yet)
* **Breakdance** — detected, read-only
* **Avada / Fusion Builder** — detected, read-only

Site owners can work around any unsupported builder by placing `[keva-region]` shortcodes around content they want Keva to manage — regions are guaranteed to work regardless of builder.

= Can I install this alongside the legacy keva-connector / keva-bridge plugins? =

Yes. See "Upgrading from Keva Connector / Keva Bridge" above. The unified plugin detects both legacy plugins and defers to them while they're active.

= Does this plugin send any data back to Keva? =

Yes — anonymous error telemetry, on by default, with multiple opt-out paths. This is how we find out when (for example) an Elementor auto-update breaks our adapter on a class of customer sites we wouldn't otherwise hear from. Without telemetry we'd be flying blind on the long tail of WordPress + builder + hosting combinations.

**What's collected:**

* PHP errors, warnings, and uncaught exceptions in Keva's code path
* WordPress version, PHP version, plugin version
* A non-reversible site fingerprint (first 8 chars of SHA-1 of the site URL) — lets us correlate errors from the same site without storing the URL itself
* The detected page builder engine and the action that failed

**What's scrubbed before sending (categorically, not best-effort):**

* All HTTP request headers except User-Agent and Content-Type — Authorization, X-Bridge-Token, and Cookie headers are dropped
* Cookies, IP addresses, session IDs
* User identity fields (user_login, user_email, display_name)
* Tokens, API keys, password hashes, nonces
* Customer post content, block content, widget settings — replaced with a size indicator so we know "this was 4.2KB of HTML content" without seeing the content
* Email addresses anywhere in event data

Errors are shipped to Sentry (sentry.io). Sentry's GDPR posture and data retention policies are documented at https://sentry.io/trust/privacy/. The legal basis for collection is legitimate interest under GDPR Article 6(1)(f), enforced via the data minimization above.

= How do I disable telemetry? =

Three ways, any one of which is sufficient:

1. **Admin UI:** uncheck the "Send anonymous error telemetry to Keva" checkbox on Tools → Keva Regions. Effect is immediate and survives plugin updates.

2. **wp-config.php constant** (best for ops/sysadmins, since it can't be undone via the admin UI):

   `define( 'KEVA_DISABLE_TELEMETRY', true );`

3. **WP-CLI** (same effect as the admin checkbox):

   `wp option update keva_telemetry_disabled 1`

The constant takes precedence over the option. When the constant is defined, the admin UI checkbox is disabled and shows a "disabled via wp-config.php" notice.

= Can I send telemetry to my own Sentry instead of Keva's? =

Yes. Define `KEVA_SENTRY_DSN` in wp-config.php with your own Sentry DSN. Useful for self-hosted Keva deployments or for customers who want all their site telemetry centralised in their own observability stack.

`define( 'KEVA_SENTRY_DSN', 'https://your-key@your-sentry.example.com/123' );`

The PII scrubber runs regardless of which DSN events are shipped to — you get the same data-minimization guarantee whether the events go to Keva or your own Sentry.

For Keva's full privacy policy: https://keva.support/privacy

== Optional Configuration ==

`define( 'KEVA_ALLOWED_IPS', array( '203.0.113.10', '203.0.113.0/24' ) );`
Restrict requests to specific source IPs (exact or CIDR, IPv4). Default is unset — any IP with a valid token is accepted. Legacy `KEVA_CONNECTOR_ALLOWED_IPS` and `KEVA_BRIDGE_ALLOWED_IPS` constants are also honored for backwards compat.

`define( 'KEVA_RATE_LIMIT', 200 );`
`define( 'KEVA_RATE_WINDOW_SEC', 300 );`
Override the default rate limit (200 requests per 5 minutes per IP).

`define( 'KEVA_DASHBOARD_URL', 'https://app.keva.support/integrations' );`
Override the dashboard URL displayed on the plugin's admin page (defaults to keva.support — useful for self-hosted Keva deployments).

`define( 'KEVA_DISABLE_TELEMETRY', true );`
Disable anonymous error telemetry entirely. See "How do I disable telemetry?" in the FAQ above for the full breakdown of opt-out paths and what's collected.

`define( 'KEVA_SENTRY_DSN', 'https://your-key@your-sentry.example.com/123' );`
Send error telemetry to your own Sentry instance instead of Keva's. The PII scrubber runs regardless of destination.

== Hooks ==

`do_action( 'keva/after_content_update', int $post_id, string $engine );`

Fires after any successful Bridge write to a page. `$engine` is one of `gutenberg | elementor | wpbakery | divi_classic | beaver | bricks | oxygen | brizy | cwicly | classic`. Use this to purge custom cache layers (Cloudflare, Fastly, edge proxies) that don't automatically hook `clean_post_cache`.

Example — purge Cloudflare cache for the affected URL:

`add_action( 'keva/after_content_update', function ( $post_id, $engine ) {
    $url = get_permalink( $post_id );
    // your CF API call here
}, 10, 2 );`

== Changelog ==

= 1.1.79 (2026-07-18) =
* Fix: Clear the two imported restore-index option caches before re-registering the validated backup source and safety snapshot, preventing stale in-request values from suppressing the database writes needed by independent full-restore read-back and compensation.

= 1.1.78 (2026-07-18) =
* Preserve full-restore source and safety-snapshot identities after database import so independent read-back and compensation remain available.
* Allow aggregate restore observers to return structured WordPress errors without a PHP return-type fatal.

= 1.1.77 (2026-07-18) =
* Make partial-restore file-state reads reuse the restore target sanitizer so ordinary filenames are not rejected by a divergent observer validator.
* Limit partial-restore file-state hashing to the requested file or subtree so independent read-back does not scan all of wp-content twice.

= 1.1.76 (2026-07-18) =
* Reload the newly installed core database version before running the in-process WordPress database upgrade, preventing a stale pre-update global from suppressing the schema update.

= 1.1.75 (2026-07-18) =
* Accept WordPress 7's installed-version string as a successful core upgrader result while retaining the independent version, database, and public-health verification gates.

= 1.1.74 (2026-07-15) =
* Fix page-text snapshot restoration to preserve slashes in Gutenberg and Classic source when WordPress saves the restored content.

= 1.1.73 (2026-07-15) =
* Add a typed provider-operation envelope for backup, plugin lifecycle, HPOS sync, transient cleanup, and page-text repair. Provider-local success remains distinct from Keva's independent verification; loopback-inconclusive transient and HPOS checks now report honestly.

= 1.1.72 (2026-07-14) =
* Cache-bust post-change homepage health probes so a host/CDN page-cache hit cannot mask a fresh server error and suppress automatic rollback.

= 1.1.70 (2026-07-14) =
* Verify Elementor placeholder-unit repairs against persisted `_elementor_data` rather than a potentially stale in-memory document tree.

= 1.1.69 (2026-07-13) =
* Add bounded, authenticated recovery-source leases for Keva safety snapshots, with lease-aware retention and observable active lease state before a restore.

= 1.1.68 (2026-07-13) =
* Reconcile Keva safety snapshots from their protected on-disk dumps after a database restore rolls the database-held index backward, so valid recovery sources remain discoverable.

= 1.1.67 (2026-07-13) =
* Require explicit percent-placeholder storage scope and a same-site public URL before any repair, with exactly one storage layer per approved write.
* Add exact token-and-layer dry-run observers for independent source verification and rollback restoration proof.

= 1.1.66 (2026-07-12) =
* Distinguish disabled HPOS compatibility synchronization from a real pending-sync incident, expose exact pending counts when synchronization is active, and avoid unnecessary snapshots or writes for expected HPOS-authoritative configurations.

= 1.1.65 (2026-07-12) =
* Add a strict UpdraftPlus provider lock that refuses instead of using a host API or creating a native fallback backup.

= 1.1.64 (2026-07-12) =
* Require exact pre/post backup IDs and UpdraftPlus job nonces for backup completion proof.
* Reject invalid supplied backup-job nonces instead of silently reading the newest unrelated log.
* Distinguish a currently active UpdraftPlus plugin from historical backup artifacts during trigger routing.

= 1.1.63 (2026-07-12) =
* Scrub stale same-version Keva update rows when WordPress reads an already-saved update transient after the plugin has been upgraded.

= 1.1.62 (2026-07-12) =
* Clear stale Keva update-transient response entries when the installed plugin is already current, so WordPress does not keep showing a same-version update after a successful update.

= 1.1.61 (2026-07-12) =
* Include Keva-native backup index entries in backup list/last-status read-back and return the native backup id from trigger_backup so fallback backups can be independently verified.

= 1.1.60 (2026-07-11) =
* Return typed REST error responses for UpdraftPlus backup trigger refusal and scheduling failures so host-policy limits surface as 409 evidence instead of a PHP fatal.

= 1.1.59 (2026-07-11) =
* Detect UpdraftPlus host-policy limits such as Kinsta one-backup-per-month and refuse those backup triggers honestly with fallback guidance instead of queueing a doomed job.
* Record the direct UpdraftPlus backup result in Keva trigger evidence when the cron wrapper invokes UpdraftPlus.

= 1.1.58 (2026-07-11) =
* Route UpdraftPlus backup triggers through a Keva-owned WP-Cron wrapper that is registered at plugin load, records durable trigger evidence by backup nonce, and exposes that evidence through job-status so a queued backup cannot silently disappear without read-back.

= 1.1.57 (2026-07-11) =
* Add read-only UpdraftPlus backup job status evidence by nonce, including sanitized jobdata, backup-history match, and a bounded/redacted log tail so async backup triggers can be diagnosed without trusting a queued message.

= 1.1.56 (2026-07-11) =
* Preserve the generated UpdraftPlus backup-now job nonce when scheduling through WP-Cron and expose read-only cron hook evidence so Keva can distinguish queued, overdue, and completed backup work.
* Keep public URL evidence from being reported as fixed when a required backup-history read-back is still unproven.

= 1.1.55 (2026-07-11) =
* Fix UpdraftPlus backup triggering on managed hosts: schedule the backup-now hook through WP-Cron and nudge cron instead of running the heavy backup inline inside the REST request, so Keva can return quickly and verify completion by independent backup-history read-back.

= 1.1.54 (2026-07-11) =
* Fix WooCommerce HPOS sync honesty: if Woo's synchronizer is triggered but a fresh read still reports hpos_in_sync=false, return an explicit failure/advisory with the safety snapshot id instead of a false success.

= 1.1.53 (2026-07-11) =
* Harden the typed `suppress_autofocus` frontend-patch runtime: it now strips autofocus immediately and also catches late plugin/widget focus events during a bounded pre-interaction window, then blurs and restores scroll when the scoped selector/context matches. This keeps the patch typed and path-scoped while reducing the chance that slow page-builder/plugin scripts focus the field after the initial polling window.

= 1.1.52 (2026-07-10) =
* Operational break-fix: added a typed frontend-patch route for public-page render failures that are not safely editable through builder content, starting with snapshot-first autofocus suppression for booking widgets that scroll the visitor away from the page header on initial load.

= 1.1.51 (2026-07-10) =
* Honesty fix: `/keva/v1/search-text` no longer reports matches found only in WordPress revision postmeta. Revisions are stale history, not current site truth; including them could make Keva think reverted text still existed and chase the wrong storage source.

= 1.1.50 (2026-07-09) =
* Restore safety: database imports now refuse SQL dumps that do not contain table-reset statements (`DROP TABLE IF EXISTS`) before replaying row inserts. This prevents an insert-only or malformed dump from partially importing over live tables, hitting duplicate keys, and leaving the database neither old nor restored. Current Keva snapshots already include the reset statements; this is a defense-in-depth guard around older or malformed sources.

= 1.1.49 (2026-07-09) =
* Updater hardening: the self-updater now bypasses Keva's private manifest transient during intentional admin update refreshes and plugin-information reads, so rapid break-fix plugin releases show up when WordPress is force-checking updates instead of waiting for the 6-hour manifest cache.

= 1.1.48 (2026-07-09) =
* Hardening: percent-placeholder escape repair now re-initializes WordPress's rewrite object before flushing generated rewrite rules, so a just-cleaned `permalink_structure` is used instead of stale in-memory rewrite state.

= 1.1.47 (2026-07-09) =
* Hardening: percent-placeholder escape repair now treats WordPress `rewrite_rules` as generated state. When leaked placeholder tokens appear there, the Bridge cleans the durable options first and then flushes rewrite rules through WordPress so the generated option is rebuilt cleanly instead of trying to edit the serialized rule cache directly.

= 1.1.46 (2026-07-09) =
* Hardening: percent-placeholder escape repair now handles stored serialized option/meta values whose string lengths were corrupted by leaked `{64-hex}` `$wpdb` placeholder tokens. The Bridge first tries normal WordPress unserialization, then repairs the raw serialized blob back to literal `%` and decodes it before writing through WordPress APIs, refusing unreadable serialized values instead of double-serializing them.

= 1.1.45 (2026-07-09) =
* Operational break-fix: added a snapshot-first percent-placeholder escape repair endpoint for cases where WordPress leaks a stored `{64-hex}` `$wpdb` placeholder token where literal `%` characters belong, such as `100{token}`, `{token}20`, or Amelia booking placeholders like `{token}appointment_date_time{token}`. The Bridge now exposes `/repair-percent-placeholder-escapes`, validates one exact observed token, supports dry-run scans across post content, post meta, and non-transient options, refuses truncated writes, takes a safety snapshot before editing, rewrites via WordPress APIs so serialized values are preserved, re-scans source, verifies site health, and optionally cache-busted-reads the affected public URL. This is separate from Elementor placeholder-unit repair and generated-asset regeneration.

= 1.1.44 (2026-07-08) =
* Operational break-fix: added a snapshot-first Elementor placeholder-unit repair endpoint for the real "HTTP 200 but CSS is broken" incident where leaked WordPress `{64-hex}` placeholders were stored in Elementor source settings such as `settings.width.unit`. The Bridge now exposes `/page/{id}/repair-placeholder-units`, which diagnoses exact placeholder tokens in unit fields, takes a safety snapshot before any write, patches only those unit leaves (defaulting to `%`), regenerates builder assets, re-reads Elementor source to prove the tokens are gone, and separately attempts a cache-busted public permalink read. Embedded or non-unit token hits are reported as suspicious instead of edited.

= 1.1.43 (2026-07-08) =
* Operational break-fix: added a page-scoped generated-builder-assets regeneration endpoint for "HTTP 200 but the page looks visually broken" incidents, Elementor-first. The Bridge now exposes `/page/{id}/regenerate-builder-assets`, which clears WordPress post/meta cache, Elementor per-page CSS/meta cache, Elementor element cache, and Elementor's sitewide generated-file cache when available, then reports before/after asset evidence. This is the safe repair path for corrupted/stale generated Elementor CSS; if leaked WordPress placeholder tokens are stored in Elementor source data, use the later placeholder-unit repair path instead.

= 1.1.42 (2026-07-07) =
* Read/list correctness (from an adversarial audit of structure-read / region / list-search; reviewed clean before release): (1) the Bridge /find-content list endpoint silently ignored the documented `status` filter (a request for drafts returned published posts too) and the `media_type` filter (a request for images returned PDFs/videos too) — both are now applied to the query. (2) It reset per_page to 20 whenever the caller asked for more than the max, silently truncating the list (which the model reads as "the item doesn't exist"); it now clamps to the real max (50). (3) list_regions with status=all returned zero regions (the sentinel was passed as a literal status value), and with no status it returned orphaned/missing_post rows; it now treats 'all' as no-filter and defaults to 'active'. (4) Writing a value to a region whose [keva-region] marker is no longer in the content (orphaned/missing_post) now fails honestly with a 409 instead of silently succeeding to a value that renders nowhere. (An accompanying dashboard-side fix re-runs the site capability census when a WordPress connector's credentials or URL change, so adding an Application Password to an existing connection is picked up immediately — that ships with the connector, not this plugin.)

= 1.1.41 (2026-07-07) =
* Data integrity (from an adversarial audit of cross-builder text fidelity): the Beaver Builder adapter saved its layout to post meta WITHOUT re-slashing, but update_post_meta() runs wp_unslash() over the value before serializing — so every string setting containing a backslash (Windows paths, code/regex snippets, LaTeX) lost one backslash level on each write, compounding on re-edit and corrupting adjacent text even when the edit itself was to an unrelated field. Beaver was the only builder that stored a live array-of-objects tree directly (the others pre-serialize to a JSON string, where a plain slash is already correct). Fixed by pre-slashing the tree symmetrically: a string-only, object-recursing slash (the exact inverse of update_post_meta's wp_unslash) — a plain wp_slash() would NOT have sufficed because it recurses arrays but not the tree's nested objects. Verified with a round-trip mechanism test. (An accompanying dashboard-side fix restores comment/media edit verification and WooCommerce product-copy rollback — those ship with the connector, not this plugin.)

= 1.1.40 (2026-07-07) =
* Correctness (from an adversarial audit of builder-shape construction; the fix itself was then adversarially re-reviewed and hardened): update_testimonial (attribution) and update_faq_item (question) on Gutenberg pages patched ONLY the block attribute (core/quote's `citation`, core/details' `summary`). Those are `source:'html'` attributes — their visible value lives in the `<cite>` / `<summary>` MARKUP, not the block-delimiter JSON — so the edit was a silent no-op: the front end never changed, yet the action reported success. Fixed with a new surgical `inner_text` patch mode that rewrites the text inside the existing `<cite>` / `<summary>` element (or inserts it when absent), preserving the nested quote/answer paragraph (an innerBlock a wholesale innerHTML replace would have destroyed). It targets the attribution `<cite>` (the last one) so a legacy flat quote with an inline body `<cite>` is not mis-edited, is backreference-safe, HTML-escapes the text, and — because the connector now sends only the markup edit (not the inert attribute) — the action reports `updated:false` and fails honestly if the surgical edit ever no-ops on an unexpected block shape, instead of false-greening.

= 1.1.39 (2026-07-07) =
* Correctness + data integrity (from an adversarial audit of i18n writes, image-swap shape construction, and the backup lifecycle; every fix then adversarially re-reviewed before release): (1) HIGH — relink_translation used pll_save_post_translations($map), which REPLACES a translation group with exactly the passed map, so relinking a subset (e.g. {en,fr} of an {en,fr,de} group) silently UNLINKED the omitted language (de) and reported verified success. It now reads each post's existing group first, PRESERVES any live member the caller omitted (the map still wins on shared languages), refuses an ambiguous cross-group conflict, and echoes preserved_members. (2) HIGH — assign_post_language on a post that ALREADY had a language detached it from its translation group (pll_set_post_language replaces the language term), orphaning its translations while reporting success. It now refuses that re-assignment (use relink_translation) and short-circuits an idempotent same-language call; it remains the intended fix ONLY for a no-language post. (3) HIGH — in-content image swap on Elementor/Bricks kept rendering the OLD image when swapping to a url with no media_id: the engines render the <img> from the image control's attachment id, and the Bridge deep-merges the nested image object, so an omitted id left the stale one in place. The swap now always sends id (cleared for an external/CDN url; upgraded to the real attachment id when the url resolves to a library item). (4) HIGH — Gutenberg image swap wholesale-replaced the block's innerHTML, dropping the original <a> link wrapper and <figcaption> caption (and desyncing attrs.linkDestination from the markup → an invalid block). It now splices ONLY the new <img> into the original markup, preserving the link + caption (backreference-safe). (5) MED — a safety snapshot taken in the same wall-clock second as its source snapshot could collide on the bare time() id/dir and overwrite the source's db.sql; snapshot ids now bump to the next free integer so a dir is never reused. (6) MED — restoring the OLDEST snapshot when the index was at cap let the restore's own safety-snapshot prune delete the source before it was read; the snapshot being restored from is now protected from that prune. (7) LOW — Elementor reorder_sections reported reordered:true even on a no-op (already-correct order); it now returns the honest reordered:false when nothing moved, matching the Gutenberg contract.

= 1.1.38 (2026-07-07) =
* Data integrity + honesty (from an adversarial audit of the read/verify surface): (1) HIGH — scan_orphaned_acf_meta / clean_orphaned_acf_meta built their "live field keys" set ONLY from `acf-field` DB posts. On a site whose ACF field groups are registered via local JSON (acf-json/) or PHP (acf_add_local_field_group) — common on professional/agency builds — there are NO acf-field posts, so EVERY live field value was mis-flagged as an orphan, and the paired destructive clean would DELETE live in-use field values it was told were dead. The live set now also includes every locally-registered field key (recursing into repeater/group sub-fields and flexible-content layouts), via ACF's own field APIs; fail-safe, so it can only ever PREVENT a false-orphan, never create one. (2) HIGH — the five destructive security writes (quarantine_file, repair_core_files, remove_rogue_user, rotate_salts, apply_hardening) hardcoded `verified:true` in their success body even when the post-op verify was INCONCLUSIVE (loopback-blocked/slow managed host) — so an op that actually fataled the front end could be reported as verified-healthy. All five now report the real three-state result plus an `inconclusive` flag (same honesty fix already applied to updates/restores). (3) MED — audit_store_config reported no_gateway_enabled from gateways merely toggled ON, not gateways AVAILABLE at checkout; an enabled-but-unavailable gateway (currency/country/amount mismatch) read as "checkout works." It now surfaces the best-effort available-gateway set + which enabled gateways are unavailable (clearly labelled advisory, since availability is computed outside a cart context) and no longer frames no_gateway_enabled=false as proof checkout completes.

= 1.1.37 (2026-07-07) =
* Correctness (chained edit ergonomics): append_block (bridge_append_block / add_widget) returned an `appended_id` that a subsequent bridge_update_block / bridge_delete_block could never match — so appending then editing/removing the same block 404'd. The four source-model builders (WPBakery, Divi classic, Oxygen, Brizy) computed the id as compute_element_id(tag, target_id, …), passing the PARENT's synthetic id where a tree PATH is expected. A Gutenberg-style "re-read and walk to target_id" fix does not work for these engines: their synthetic id hashes the element's rendered form (which, for a container, INCLUDES its children), so appending a child shifts the container's OWN id — target_id is not stable across the append. Instead each adapter now diffs the annotated id-set before vs after the write and returns the unique NEW element whose (tag/type + rendered/value signature) matches the inserted block. If the match is not unique (or the re-render doesn't round-trip byte-for-byte) it returns '' rather than a fabricated id, so the caller re-reads structure to locate the block — never a wrong id. The whole locate step is wrapped so a parse hiccup can never turn a successful write into an error. (Gutenberg + Elementor already returned a correct id.)

= 1.1.36 (2026-07-07) =
* Honesty (no silent no-op false-green): update_block (bridge_update_block) reported updated:true whenever it MATCHED the target element — even when the patch carried no applicable field for that engine (e.g. a Gutenberg-shaped { attrs } sent to a Bricks element that needs { settings }, or an empty patch). The edit "succeeded" while the page was unchanged, defeating any read-back that trusts the action result. Fixed across all five affected adapters (Bricks, Beaver Builder, Brizy, Gutenberg, WPBakery): the write now only reports success when a field was actually applied — a matched-but-no-applicable-field patch returns an honest error (or not-updated) and never re-writes the content. The source-string builders that already gated success on real application were unaffected.

= 1.1.35 (2026-07-07) =
* Safety + data integrity (from an adversarial audit of the highest-blast-radius paths — the destructive snapshot/restore spine): (1) CRITICAL — the pure-PHP database dump (the ONLY snapshot path on a managed host with no mysqldump/exec) accepted a TRUNCATED dump as a valid safety snapshot. It guarded only `filesize > 0`, never completeness — so if the disk filled mid-dump, the failed writes were swallowed and a partial db.sql was recorded as a good snapshot, then used as the rollback point for every destructive op (restore, remove_rogue_user, apply_hardening, ACF/i18n cleanups, …). On rollback that truncated dump replays into a corrupt half-restored database with no valid recovery point. The mysqldump path already guarded this via a completeness check; the pure-PHP path now does too: fwrite failures are tracked and the dump is REJECTED unless every write landed and the "-- Dump completed" trailer is present. The restore side also now refuses a truncated dump as a source (defense-in-depth for any pre-existing snapshot). (2) The Oxygen builder's attribute round-trip decoded ALL HTML entities on read (`&amp; &lt; &gt; &#039; &quot;`) but re-encoded ONLY the double-quote on write, so any other entity in a ct_text/ct_headline/ct_button value (rich-text content essentially always has them) was written back as a bare literal — silently corrupting the stored shortcode on every edit while reporting success. Serialization is now the exact inverse of the decode. (3) restore_database_only / restore_backup / restore_partial hardcoded `verified:true` in their success body even when the post-restore verify was INCONCLUSIVE (loopback-blocked managed host) — the same false-"verified" honesty bug fixed in the updates path in 1.1.34. All three now report the real three-state result plus an `inconclusive` flag.

= 1.1.34 (2026-07-06) =
* Data integrity (from a pre-flight static audit of every un-greened action — four "unit-green, real-broken" bugs that plain-ASCII tests never hit): (1) The Gutenberg content writer (bridge_replace_text / bridge_delete_block / reorder_sections / append / duplicate) and the Cwicly, classic-Divi and WPBakery post_content writers wrote back WITHOUT wp_slash. Because wp_update_post()->wp_insert_post() wp_unslash()es its input, any block carrying a literal backslash (a code/preformatted block with a regex or Windows path, or an escaped char in the block-delimiter attr JSON) lost one backslash level on the next edit — silently corrupting the whole re-serialized post while reporting success. All four now wp_slash, matching the Classic writer that already did. (2) The Elementor render-regenerate path had the same omission on both its post_content write and its defensive _elementor_data restore, corrupting the editable tree's JSON when a save_post listener touched the meta; both now wp_slash. (3) rotate_salts' define()-rewrite regex used [^;]* for the value, which cannot span a `;` INSIDE a salt value (both api.wordpress.org and wp_generate_password extra-special charsets include `;`) — a second rotation failed to match that key, appended a DUPLICATE define PHP ignores (first wins), and reported the salt rotated when it was not. The pattern is now quote-aware (spans any char inside a quoted value, still matches getenv-wrapped defines, and stops at the first statement terminator). (4) update_plugin / update_theme / update_core / rollback_plugin hardcoded verified:true in their success body even when the post-write verify was INCONCLUSIVE (a loopback-blocked managed host where the site could not be confirmed) — a false "verified" on exactly the hosts that block the loopback. The top-level `verified` now reflects the real three-state result and a new `inconclusive` flag surfaces applied-but-unconfirmed. (No behaviour change on hosts that already round-tripped cleanly.)

= 1.1.33 (2026-07-06) =
* Reliability (restore at real-site scale — unblocks Tier-0 rollback on managed hosts): the pure-PHP database restore path (used when a host has no mysql client / exec, i.e. most managed hosts like Kinsta and WP Engine) read the ENTIRE SQL dump into memory with file_get_contents() and then split it into an array of every statement — holding the whole dump twice. A real-site ~199 MB restore OOMed a 256 MB request and left restore_database_only / restore_backup / restore_partial unusable on exactly the hosts that need them. The importer now STREAMS the dump: it reads in 256 KB blocks and executes each statement as its top-level `;` is seen, so peak memory is bounded to one block + one statement (a 50 MB dump now streams with ~0 MB added peak vs 50 MB+ for the old read) — independent of dump size. The statement parser is byte-for-byte the same as before (single/double quotes with escapes, backtick identifiers, and `--`/`#`/`/* */` comments are all honoured so a `;` inside a literal never splits a statement), verified equivalent to the previous splitter across every block boundary and confirmed with a full dump→drop→restore round-trip. The database DUMP side already streamed (paged row-by-row), so snapshots were never affected — this is purely the restore/import side.

= 1.1.32 (2026-07-06) =
* Reliability (managed-host timeout fix 3 — SEO reads, server side): check_sitemap and check_redirects could still hang to a full timeout on managed hosts (Kinsta/WPE) even after the connector-side external-fetch fix, because the Bridge handlers do their in-WP loopback work SYNCHRONOUSLY at the 20s LOOPBACK_TIMEOUT before returning — check_sitemap fetches+parses the whole sitemap, and check_redirects follows up to 5 sources × ~10 hops via loopback, compounding well past the connector's request window. Since the connector already re-fetches each of these EXTERNALLY (authoritative served / served_follow), the in-WP loopback is best-effort and is now bounded: a new short SEO_PROBE_TIMEOUT (6s) for the sitemap fetch + homepage meta parse, and a wall-clock budget (SEO_FOLLOW_BUDGET_S, 8s) on the redirect-follow loop that stops early and reports budget_reached:true instead of hanging. All the DB-derived fields (sitemap_url, redirect_sample, .htaccess scan, seo_plugin) are unaffected — they never used the loopback. Detection quality is unchanged on hosts that allow a fast loopback. Companion connector-side change raises the SEO read client timeouts (check_sitemap 15s, audit_seo_meta 15s, check_redirects 20s) to match the bounded server work.

= 1.1.31 (2026-07-06) =
* Reliability (managed-host timeout fix, server side): the slow security scans and performance reads could hang to a full timeout on managed hosts (Kinsta/WPE) — either fanning out one SERIAL wordpress.org round-trip per plugin, or looping a server-to-self loopback that those hosts block. They now fail fast and stay inside the host request window. (1) `scan_plugin_integrity` + `detect_vulnerable_extensions`: the per-plugin .org checksum/info fetches are capped (top-N = 12) and wall-clock-budgeted (18s); a large site now returns an honest PARTIAL (`truncated:true`, `checked`, `total_plugins`) instead of timing out, and a scoped `?slug=` request is never capped. The two .org fetch timeouts drop 15s → 5s. (2) `detect_perf_issues`: the page-cache probe now uses a short 5s timeout and FAILS FAST on the first blocked loopback (a host that blocks server-to-self was retrying 3× the timeout for the same error). (3) `measure_plugin_load_impact`: the per-plugin A/B loopback is capped (top-N = 8) + wall-clock-budgeted, returns a ranked PARTIAL, and its probe timeout drops to 5s; a `?slug=` request always measures its target. Detection quality is unchanged on hosts that allow loopback — this only bounds the worst case. Companion connector-side change raises the client read timeouts to match the bounded server budgets.

= 1.1.30 (2026-07-05) =
* Hardening (from a proactive false-green audit): two maintenance-write handlers could report success while doing nothing on a no-op run (the same shape as the 1.1.29 trim_revisions bug). Both now carry the trim Step-2.5 re-count guard. (1) `clean_action_scheduler`: it purges via WooCommerce's Action-Scheduler API (`as_get_scheduled_actions`), which can silently return an empty set on a status-label/store-backend quirk — reporting a 0-row purge as a green no-op. It now, after purging nothing, runs a DIRECT-SQL count of terminal actions older than the cutoff (independent of the AS API) and returns an honest error (not success) when candidates still exist. (2) `clear_transient_bloat`: it now reports `cleared` per the real before/after delta and returns an honest error when 0 rows were removed while transients were present, instead of always reporting `cleared:true`. Neither can now report a silent no-op as done; genuine nothing-to-do runs still return an honest 200.

= 1.1.29 (2026-07-05) =
* Fix (found browser-real): `trim_revisions` could report a successful, "verified" trim while deleting NOTHING — a silent no-op. On a Kinsta site a keep-10 trim left a post with 246 revisions yet the action reported done/verified. Two causes: (1) the delete loop chose its targets from `wp_get_post_revisions()`, whose `get_children()` result is object-cached and returns a stale/empty list on hosts with a persistent object cache, so the loop deleted nothing; (2) the handler still returned `trimmed:true / verified:true` even when `deleted_count` was 0. Now: revision IDs to delete are read via a DIRECT `$wpdb` query (fresh, never the object cache), the post cache is cleaned around each mutation, and after deleting the handler RE-COUNTS excess via fresh SQL — if any post still exceeds `keep` (or nothing was deleted despite excess existing), it returns an HONEST failure with the intact safety-snapshot id instead of a false success. A silent no-op can no longer be reported as done. Same persistent-object-cache class as the reorder read-after-write issue.

= 1.1.28 (2026-07-05) =
* Harden reorder change-detection (defensive, latent bug): the position-independent `content_key` that `reorder_sections` uses to decide whether an order actually changed now includes nested `innerBlocks` recursively. Previously it hashed only a block's TOP-LEVEL `innerHTML`, so two container sections (media-text, columns, group, cover) that share wrapper markup but differ only in nested content collided to one key — a latent case where a real swap of them could be mis-read as a no-op (`reordered:false`). Covered by an isolated PHP test. NOTE: this is a defensive fix for a collision that was NOT the cause of the reorder issue observed on Kinsta — there `reorder_sections` returned `reordered:true` (change detected + written) yet read-back showed the old order, which points to read-after-write staleness in a persistent object cache, tracked + fixed separately, not here.

= 1.1.27 (2026-07-05) =
* Fix: appending a new SECTION that contains child blocks (e.g. add_section, or any core/group wrapping a heading) no longer drops the children. `validate_block_input` built `innerContent` as a single string chunk (`array( innerHTML )`) with NO null placeholders, but WordPress's `serialize_blocks()` renders an innerBlock only where it hits a null chunk in `innerContent` — so zero nulls meant zero children serialized, and a group-with-heading saved as an empty self-closing void (`<!-- wp:group /-->`) with the heading silently gone. Block input now normalizes recursively and builds a correct `innerContent` with one null placeholder per child (wrapped in the canonical `<div class="wp-block-group|columns|column">` for the common structural containers), so nested content survives the write. Found browser-real; covered by an isolated PHP serializer test.

= 1.1.26 (2026-07-05) =
* Improve (SEO diagnosis): the "why isn't my site on Google" diagnosis no longer depends on the in-WP loopback that managed hosts (Kinsta, WP Engine) block. `audit_indexability` now also carries an authoritative `served` block — the real HTTP status + robots meta + `X-Robots-Tag` from an EXTERNAL fetch of the homepage by Keva's backend (the network path Googlebot uses, which the loopback can't reach) — plus a `noindex_sources` enumeration of every independent source hiding the site (core `blog_public`, the SEO plugin's config, an `X-Robots-Tag` header, a theme/meta noindex). Builds on 1.1.23's loopback-honesty: where the loopback could only say "couldn't verify," the external read now supplies the real answer.
* Fix (SEO keep-partial + escalate): `set_search_visibility` now auto-reverses ONLY on a site-*health* failure. When the `blog_public` flip succeeds but ANOTHER source is still noindexing (for example, a host/CDN/server/plugin-layer `X-Robots-Tag` header), the flip is KEPT — `blog_public=1` is the correct state for a public site — and the response reports `residual_noindex` + the remaining source(s) for escalation, instead of silently rolling back a correct change. The post-fix verification is now driven by Keva's external served read (reliable where the loopback is blocked), which supersedes the loopback-based `front_end_verified`.

= 1.1.25 (2026-07-05) =
* Fix: find/replace (update_page_text) now reaches text inside structured Gutenberg blocks — `core/table` cells/caption and `core/pullquote` body/citation. Previously the block walker only replaced text in a fixed set of leaf blocks (paragraph, heading, quote, button, list-item…) and recursed into container innerBlocks; a table keeps its cell text in its own innerHTML with no innerBlocks, so "change Pro to Premium" on a table matched nothing and reported "No occurrences found" — the user had to know to ask for a direct block patch instead. These blocks now run through a text-node-only replace (only characters between `>` and `<` are touched), so cell/citation text changes without corrupting the surrounding `<table>`/`<td>`/class markup or an inline link URL. Read structure now also shows a text hint for these blocks. (core/details summary text — a mixed innerHTML + innerBlocks case — remains a known gap.)

= 1.1.24 (2026-07-04) =
* Fix: Site Health diagnostics returned all-"unknown" on modern WordPress. `WP_Site_Health::perform_test()` is a private method (confirmed on shipped cores from at least WP 5.9 through 7.0), so the diagnostics bridge could not call it — every native direct test threw "Call to private method" and was recorded as "unknown", and the Site Census health section degraded with it. The site-health handler now invokes the public `get_test_*()` callbacks directly and re-applies the `site_status_test_result` filter (the whole body of core's `perform_test()`), guarded by a capability check (`is_callable`) that defers to the native method on older cores where it is still public. Verified on WordPress 7.0.1-RC1: 22 direct tests now return real good/recommended/critical statuses instead of 22 "unknown".

= 1.1.23 (2026-07-04) =
* Fix (SEO honesty): a FAILED front-end loopback no longer reads as a positive fact. `parse_front_end_robots` now returns `null` (UNKNOWN) — not `false` — for `meta_noindex` / `header_noindex` when the loopback can't fetch the front end, and `fetch_served_robots` does the same for `blocks_crawling`. Previously a loopback timeout on a managed host (Kinsta/WP Engine) surfaced a hard `false`, which a consumer could misread as "no noindex present" / "not blocking crawling."
* Fix (SEO verification honesty): after applying a fix, a post-fix loopback that can't be reached is now reported as APPLIED-but-UNVERIFIED, never verified-success. `set_search_visibility` and `fix_robots_txt` only auto-reverse on a *positive* disproof (the loopback fetched AND still sees the problem); a failed loopback leaves the authoritative in-process write in place, sets `verified:false` (+ explicit `front_end_verified` / `served_verified`), and says so in the note. Data-layer companion to the copilot loopback-honesty rule.

= 1.1.19 (2026-07-02) =
* Fix: Classic Editor posts/pages are now writable via find/replace. `adapter_for()` had no `classic` case, so every text write to a Classic post returned `engine_not_writable` even though `is_writable` reported true and the read path round-tripped the content fine. Adds a `Keva_Support_Classic` adapter whose `replace_text` is HTML-aware — it splits on tags and only replaces inside text segments, never inside markup/attributes — and persists via `wp_update_post`. Structural ops (append/update/delete/duplicate/reorder) return a clear `engine_not_supported` error since Classic content has no addressable block ids.

= 1.1.18 (2026-07-01) =
* Fix: Gutenberg block `attrs` patches now deep-merge instead of shallow-merge. Previously `update_block` on a Gutenberg block used `array_merge`, so patching a nested attribute (e.g. a button's `style.color.text`) replaced the whole `style` object and silently dropped its siblings (`style.spacing`, `style.typography`). Now mirrors the Elementor path's deep-merge (associative keys merge key-by-key; scalars and list arrays replace wholesale), so a nested-leaf patch preserves the rest of the block's attributes.

= 1.1.17 (2026-07-01) =
* Fix: appended blocks now get proper Elementor element IDs at every depth. Previously `append_block` only assigned an ID to the top-level element, so child widgets were added with empty IDs and could never be targeted again by ID-based tools (you could add a section but not edit its contents). `validate_element_input` now recurses and IDs every descendant.
* Fix: `update_block` no longer reports success on a no-op — it returns `updated: true` only when a `settings` patch actually applied; a malformed or empty patch is now an honest no-op instead of a fake-green.

= 1.1.16 (2026-07-01) =
* Bridge reads are no longer edge/CDN-cached. Because Bridge requests authenticate with the X-Bridge-Token header (not a WordPress login), WordPress treated them as anonymous and some hosts/CDNs (observed on Kinsta) tagged the REST responses `Cache-Control: public, s-maxage=86400` and cached them for up to a day. The effect: right after a successful edit, reading the page back returned a stale snapshot (the write had persisted, but the read showed old content) — which could make an edit look like it failed, or even trigger a rollback of a good change. Every keva/v1 response now sends no-store/no-cache headers so reads always reflect the latest content.

= 1.1.15 (2026-06-27) =
* Text edits now match what you see, not the raw HTML: replace_text (update_page_text) for Elementor falls back to HTML-aware matching when an exact match finds nothing. Previously a visible phrase that spanned a hyperlink or other inline markup, or contained HTML entities (&middot;, &nbsp;) / collapsed whitespace, was stored as several pieces and a byte-exact search matched nothing — so e.g. a footer credit "Created by Sant · Powered on Sant Cloud" with linked words could not be changed. The matcher now strips tags, decodes entities and collapses whitespace to find the visible text, then splices the replacement back in (dropping inline markup inside the match and absorbing tags that hug the boundary so no orphan tag is left). Mirrors the connector-side fallback.

= 1.1.12 (2026-06-18) =
* Link / button URL editing: update_block now deep-merges a nested-leaf settings patch, so changing where a button points (e.g. settings.btn_link.url / settings.link.url) preserves the link's sibling keys (is_external, nofollow, custom attributes) instead of replacing the whole link object. Previously a shallow merge dropped them. Verified on wp-env with a red/green test (elementor-link-update).

= 1.1.11 (2026-06-13) =
* Fix Elementor edits not rendering on some sites: the post_content regeneration (added in 1.1.10) now busts Elementor's per-request Document cache and persisted render caches BEFORE re-rendering, so the fresh render reflects the just-saved data instead of a stale copy. 1.1.10 could render the OLD content on sites where Elementor cached the document. (Stock free Elementor was unaffected; seen on Elementor 4.x / Pro theme-builder templates.)

= 1.1.10 (2026-06-13) =
* Elementor edits now render: after a Bridge write the rendered HTML fallback in post_content is regenerated from the element tree. Themes/contexts that render via the_content() — theme footer/header builder CPTs especially, plus SEO/AMP/feeds — previously kept showing the old text because only _elementor_data was updated. _elementor_data remains the source of truth; regeneration is best-effort and never fails the write or overwrites with an empty render.

= 1.1.9 (2026-06-11) =
* Page edits now work on template and theme post types: the Bridge accepts posts/pages, template types (wp_template, wp_template_part, wp_block, elementor_library), and any custom post type carrying page-builder data its adapters can edit (Elementor, Bricks, Beaver) — e.g. theme-specific footer templates. Internal CPTs without builder data are still refused.

= 1.1.8 (2026-06-11) =
* New read-only endpoint `GET /keva/v1/search-text?q=<text>` — site-wide text locator. Finds where a piece of text lives (post content of every post type incl. templates/template parts/reusable blocks, page-builder data in post meta, widgets, customizer settings, site identity). Lets Keva answer "where does this footer text live?" before editing.

= 1.1.7 (2026-06-10) =
* Connect in one step: paste your site URL + the Bridge token when adding WordPress under Keva → Integrations → Platform Connectors. The WordPress username + Application Password are now optional extras (they enable user-account / comment / media actions).
* Keva → Status admin page: Bridge token card listed first (the recommended full connection), branded design, click-to-copy fields, inline generate confirmations (no browser pop-ups), live Connected indicator driven by real heartbeats.
* Post-activation redirect to Keva → Status + a "finish connecting" notice that disappears once Keva reaches the site.
* **Deleting (not deactivating) the plugin now removes all Keva data — tokens, settings, captured diagnostics, and the regions table.** Deactivation still preserves tokens. After a delete + reinstall, generate fresh tokens and reconnect in Keva.
* Fixed activation fatal on PHP below 8.2 (Composer platform check in the bundled telemetry SDK — telemetry now loads only on PHP 8.2+).

= 1.0.0 =
* **Unified plugin** — replaces the separate `keva-connector` and `keva-bridge` plugins with a single install. Existing customers can run alongside the legacy plugins during transition; new customers install one ZIP.
* All `keva-bridge` v0.7 features carried forward:
  * 9 builder adapters: Gutenberg, Elementor, WPBakery, Divi Classic, Beaver, Bricks, Oxygen, Brizy, Cwicly
  * `[keva-region]` shortcode + `keva/region` Gutenberg block
  * Tools → Keva Regions admin page
  * Layout primitives (duplicate-block + reorder-children REST endpoints, Gutenberg + Elementor only)
  * Per-engine cache invalidation
  * `keva/after_content_update` hook
* All `keva-connector` v1 features carried forward:
  * Post / page / comment CRUD via `/wp-json/keva-connector/v1/*`
  * Heartbeat diagnostics
  * Self-updater (polls `KEVA_UPDATE_MANIFEST_URL`)
  * Rate limiter
* New: legacy-plugin-detection — when `keva-connector` or `keva-bridge` is also active, the unified plugin defers route registration on the overlapping namespace so the legacy plugin remains authoritative. Lets customers deactivate the legacy plugins at their own pace after verifying the unified plugin works.

== Upgrade Notice ==

= 1.1.7 =
One-step connection (site URL + Bridge token) and a redesigned Keva → Status page. Note: DELETING the plugin now removes all Keva data including tokens — your site will need to be reconnected with fresh tokens. Deactivating still preserves everything.

= 1.0.0 =
First release of the unified plugin. Replaces Keva Connector + Keva Bridge with a single install. Safe to run alongside the legacy plugins during transition.
