=== OW Consent ===
Contributors: optionweb
Tags: gdpr, ccpa, consent, cookie-banner, privacy
Requires at least: 6.2
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 1.4.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

GDPR, CCPA and TCF v2.2 consent management: cookie banner, tracker blocker, scanner, DSAR workflow and hash-chained consent ledger.

== Description ==

OW Consent is a consent management suite for WordPress. Eleven compliance profiles ship with it, from the GDPR and the UK PECR to the CCPA, the LGPD, PIPL and Quebec Law 25; the one in force decides the consent model, the Consent Mode defaults and the documents generated.

This version requires WordPress 6.2 or later. Sites on 6.0 or 6.1 are not offered the update.

= Works behind a full-page cache =

Nothing on the front end depends on the visitor's consent cookie: the HTML is identical for every visitor, and the consent state is rebuilt in the browser from the cookie and applied before first paint. Trackers are blocked in the markup for everyone and released client-side for the categories the visitor granted, so a page cache (LiteSpeed, WP Rocket, Varnish, a CDN) cannot serve one visitor's choices to another.

= What it includes =

* Cookie banner: four positions, light, dark or automatic theme, "Reject all" as prominent as "Accept all", keyboard operable, translatable.
* Tracker blocker: scripts, inline snippets, iframes, pixels, resource hints, analytics stylesheets and media, from 175 editable signatures.
* Consent ledger: every action appended to a hash-chained table signed with HMAC-SHA-256, with the profile and the banner actually shown.
* Data-subject requests: shortcode form, e-mail verification, the Article 12.3 deadline, WordPress's own exporter and eraser hooks.
* Legal documents: a privacy notice and a cookie policy per profile, in that profile's language, fed by a site scanner that sweeps your own pages on WP-Cron, off by default.
* Google Consent Mode v2, IAB TCF v2.2, Global Privacy Control, geo routing, OW Forms integration: each off unless you enable it.

= Limits, stated plainly =

The generated documents are drafting aids, not legal advice: have them reviewed before publishing. The scanner reports what your HTML contains plus what an administrator-only probe observed; it is not a completeness guarantee. The TCF module is not a CMP registered with IAB Europe and offers no vendor-level choice, so vendors may reject its signal. The ledger is tamper-evident only while its signing key lives outside the database, and warns you when it is not.

Each module is documented in the FAQ below.

== External services ==

OW Consent contacts one external service, and only when you enable the feature that requires it.

= IAB Europe Global Vendor List =

Used by the IAB TCF v2.2 module, which is disabled by default. The TCF framework requires the current list of registered ad-tech vendors; the plugin downloads it so it can build a TC String and expose the vendor set through `__tcfapi`.

When it happens: only when the "IAB TCF v2.2" option is enabled, from a daily WP-Cron job (or from the settings screen when you enable the option). It is never fetched while a visitor's page is being rendered, and the public `owc/v1/tcf/gvl` REST route serves the cached copy only — it never triggers an outbound request.

What is sent: nothing. The request is a plain HTTP GET of a public JSON file at `https://vendor-list.consensu.org/v3/vendor-list.json`. No site data, no visitor data, no personal data and no identifier is transmitted; the only information the service receives is what your server necessarily discloses by making an HTTP request (its IP address and its user agent). The response is stored in a WordPress transient for one week and served from that cache; if a refresh fails, the previously cached list is kept.

Service provided by IAB Europe. Terms and conditions: https://iabeurope.eu/terms-and-conditions/ — Privacy policy: https://iabeurope.eu/privacy-policy/

= Requests to your own site =

The site scanner issues HTTP requests to your own site's public URLs in order to inventory the trackers your pages load. These requests never leave your own host: the URL list is restricted to your site's host after the `owc_scanner_urls` filter has been applied. No third party is involved.

= MaxMind GeoLite2 =

If you place a GeoLite2 country database in `wp-content/uploads/ow-geoip/` and enable the option, the plugin reads that local file with its own bundled reader. It never contacts MaxMind, and it never sends an IP address anywhere: the lookup happens on your server and the address is discarded immediately.

== Installation ==

1. Install the plugin from Plugins > Add New > Upload Plugin, or upload the `ow-consent` folder to `/wp-content/plugins/`.
2. Activate it through the Plugins menu.
3. Open the **OW Consent** entry in the WordPress admin sidebar. The whole plugin lives on that single screen, in tabs: Dashboard, Banner, Compliance, Legal identity, Categories, Policies, Scanner, Tracker catalogue, Audit ledger and DSAR requests.
4. Set your jurisdiction and consent model on the **Compliance** tab, then fill in the **Legal identity** tab. Those fields are what the generated documents are built from, and generation is refused while a mandatory one is empty.
5. Generate your documents from the **Policies** tab, and have them reviewed before you publish them.
6. Optionally, add `[owc_dsar_form]` to a page to open the data-subject request portal, and `[owc_dnsmpi]` where you want the "Do Not Sell or Share" control.
7. Recommended: define `OWC_LEDGER_KEY` in `wp-config.php` so the consent ledger's signing key does not move with the WordPress salts, and make sure `AUTH_KEY` and `AUTH_SALT` are defined there as well.
8. If your site sits behind a CDN, a load balancer or any reverse proxy, declare it with `OWC_TRUSTED_PROXY` in `wp-config.php` (the plural `OWC_TRUSTED_PROXIES` is accepted as an alias), giving the proxy addresses or CIDR ranges. The rate limiting on the public REST endpoints keys on the client address; when every request arrives from the same proxy address and the proxy is not declared, the plugin cannot tell one visitor from another and falls back to a per-visitor split backed by a wider site-wide ceiling, which is a coarser limit.

== Frequently Asked Questions ==

= Does the plugin work with a page cache? =

Yes, and it is designed around that. The front-end HTML it produces is identical for every visitor; consent is read from the cookie in the browser and applied before the page is painted. Trackers are blocked in the cached markup and released client-side. Only genuinely per-visitor responses — the DSAR verification page and REST responses containing personal data — are marked uncacheable. No nonce is printed in cacheable HTML either; the banner fetches one from a no-store endpoint before each write.

= Does this plugin replace a paid CMP like OneTrust or Cookiebot? =

For most sites it covers the same ground: banner, blocking, audit log, data-subject requests, geo routing and generated documents. Two things it does not do: it is not a CMP registered with IAB Europe (see the TCF question below), and it does not provide legal advice or reviewed legal documents — the generator produces drafts that you must have reviewed.

= Is the ledger tamper-proof? =

It is tamper-evident, under one condition. Each row is signed with HMAC-SHA-256 over the previous row's hash, so a modified or deleted row breaks the chain and verification reports where. The guarantee rests on the signing key being outside the database: that holds when `AUTH_KEY` and `AUTH_SALT` are defined in `wp-config.php` (the WordPress default), or when you define `OWC_LEDGER_KEY` there. If those constants are absent, WordPress stores the salts in the database, and an attacker with database access could re-sign the chain — the plugin detects that situation, records it and warns you in the admin. Rotating the salts is distinguished from tampering in the verification report.

= Does the scanner slow down my site? =

No. It is off by default, and once enabled it runs as a WP-Cron job at the frequency you choose (weekly by default), fetching a sample of your own pages server-side. It never runs during a visitor's page view. Note that WP-Cron only fires when your site receives traffic, or when you have set up a real cron task; on a site with `DISABLE_WP_CRON` and no system cron, the scheduled scan will not run.

= Does the scanner find cookies? =

Partly. The scheduled server-side sweep reads the `Set-Cookie` headers your pages return, so it finds cookies set by the server, including HttpOnly ones, as well as scripts, iframes, stylesheets and pixels. It executes no JavaScript, so cookies written by scripts in the browser are not visible to it; those are collected by a diagnostic probe that by default runs only for logged-in administrators. Treat the result as an inventory of what was seen, not as an exhaustive list — and both screens say so rather than leaving you to assume otherwise: the Scanner tab states whether the last sweep finished, how many pages it read, how many it could not fetch and whether a browser has ever reported back, and the generated cookie policy repeats the same reservations to the reader.

= How does a data-subject request get fulfilled? =

The visitor submits the form and receives a verification e-mail. Confirming the request on the linked page starts the Article 12.3 deadline and opens a WordPress personal-data request. From the DSAR screen you can download a JSON export produced by every exporter registered on the site (OW Consent's own records, WordPress core's, and any other plugin's), run every registered eraser, and close the request with a written response that is stored with it. What the export and the erasure cover depends on which plugins on your site register those hooks; anything that registers none has to be handled by hand.

= Does it integrate with OW Forms? =

Yes, when OW Forms is active and the integration is enabled. Every stored submission is linked to the consent in force at that moment: the pseudonymous browser token, the ledger entry that was current, the categories granted, the compliance profile, the hash of the published documents, the timestamp, and the exact wording of the consent checkbox that was accepted. Erasure requests cascade to OW Forms submissions and their uploaded files, matched on the e-mail hash OW Forms itself stores, and OW Forms submissions are included in WordPress's Export and Erase tools.

Three limits are worth stating: no consent event is written into the ledger for a submission (the link references the entry already in the chain); submissions recorded before 1.2.0, and submissions made while OW Forms is configured not to store them, have no link; and the e-mail address remains stored in clear text by OW Forms, because it is needed to answer the sender — the hash is a lookup key, not a privacy measure.

= Can I use the TCF module for Google AdSense or Ad Manager? =

Only with your own CMP ID issued by IAB Europe, and even then with a caveat. Without a CMP ID the module does not load at all. With one, it emits a correctly encoded TC String and the panel displays every purpose and special feature that string can claim, but TCF policy requires a registered CMP to present purpose-level and vendor-level choices of its own; here the purpose switches follow the categories and there is no vendor-level choice, so it is not a registered CMP and vendors are entitled to reject its signal. If TCF-gated advertising revenue matters to your site, use a certified CMP.

= What language are the generated documents in? =

The privacy notice and the cookie policy are generated for all eleven profiles in the jurisdiction's own language: English, French for France, Belgium, Luxembourg and Quebec, Portuguese for Brazil. The legal notice and the terms exist only in French, built around French law; for any other jurisdiction the generator refuses to produce them rather than publishing an inappropriate document.

= What can I change about the banner? =

Four positions (bottom bar, bottom card, centre modal, top bar), a light, dark or automatic theme, an optional site logo (your custom logo, falling back to the site icon), configurable labels and a floating re-open button. Any link or button carrying the class `owc-open-preferences` reopens the preferences panel.

"Reject all" is rendered with the same prominence as "Accept all". The close cross is disabled by default; when you enable it, closing the banner is recorded as a refusal of every optional category, never as a silent dismissal. It is present in the markup either way, hidden while the setting is off, and it is revealed and relabelled when a refusal cannot be recorded at all: a visitor whose choice the server will not accept is not left facing a banner they cannot dismiss, and that dismissal is not counted as a choice. The panel is keyboard operable, uses `role="switch"` toggles with a visible on/off state, traps focus in modal mode and restores it on close. Banner copy is translatable and can be replaced through the `owc_banner_texts` filter.

= What exactly does the tracker blocker block? =

Third-party scripts, inline tracking snippets, iframes, tracking pixels (including inside `noscript`), analytics and marketing stylesheets and media sources are rewritten before consent and released afterwards, in document order. Resource hints (`preconnect`, `dns-prefetch`, `preload`) aimed at a catalogued third party are dropped outright rather than deferred, since a hint opens a connection and has nothing to restore. Stylesheets and web fonts served by a host classified as functional are left alone: neutralising them costs every first-time visitor a fallback-font page and buys no consent. Lazy-loading attributes used by caching plugins are neutralised so a lazy loader cannot restore a blocked URL. Blocked embeds are replaced by a placeholder carrying a per-category unblock button.

The plugin ships 175 detection signatures in `data/tracker-catalog.json` (110 URL patterns and 65 cookie names). The catalogue is editable from the Tracker catalogue screen — add, edit, delete, search, plus a view listing the rules that cannot match — and extensible through the `owc_scripts_catalog` filter. Unknown third-party scripts are allowed by default and unknown third-party iframes are blocked by default; both are settings, and a host allowlist (also exposed as the `owc_blocker_allowlist` filter) always wins.

= What is recorded in the consent ledger? =

Every consent action (accept, reject, partial save, GPC opt-out, renewal, withdrawal) is appended to a hash-chained table. Each row's HMAC-SHA-256 covers the previous row's hash and is written inside a transaction, so concurrent writes cannot fork the chain. Rows store a pseudonymised IP (IPv4 last octet zeroed, IPv6 truncated to /48), a user-agent hash, the page URL, the compliance profile, a hash of the legal documents in force, a fingerprint of the banner actually displayed and the plugin version — the elements needed to reconstruct what the visitor was shown, for GDPR Article 7(1) accountability.

Chain verification walks the whole chain, reports the number of rows actually checked and describes each break individually, including a key rotation, which it distinguishes from a rewrite. Rows written before 1.2.0 carry an unkeyed digest that anyone with database access can recompute; verification counts them separately and refuses to declare a chain intact while any of them remain, because an unkeyed digest is not evidence.

= What safeguards are on the data-subject request form? =

The shortcode form (`[owc_dsar_form]`) collects access, rectification, erasure, portability, restriction, objection and CCPA opt-out requests. Submitting it requires an explicit declaration that the right is being exercised over the requester's own data, checked server-side and recorded with the request. The requester receives a verification e-mail; the link opens a confirmation page, and the request is only confirmed by an explicit action on that page, so a mail security scanner following the link cannot confirm an identity on the subject's behalf. The token is single-use, stored only as a SHA-256 hash, and expires after a configurable number of days (7 by default).

Verification starts the Article 12.3 clock (30 days by default, configurable from 1 to 30) and opens a matching WordPress core request. Reminders are sent seven days before the deadline and once it has passed, and the one-off two-month extension permitted by Article 12.3 notifies the data subject of the reasons. A request whose author never confirmed their identity cannot be exported, erased or recorded as honoured: the check is enforced on the server, not only by hiding the buttons, so nobody can assemble or destroy the record held about an address on the strength of a claim that was never proved.

= What does the legal document generator produce? =

A privacy notice and a cookie policy for each of the eleven compliance profiles, written against that jurisdiction's own statute and in that jurisdiction's language. The legal notice and the terms exist as French documents only; generation for a non-francophone profile is refused with an explicit error rather than publishing a French document to an English-speaking audience.

Documents are built from the settings on the Legal identity tab, and generation is blocked with the list of the fields still empty rather than publishing blank clauses. The cookie policy lists what the scanner actually found, with the vendor and a link to that vendor's own privacy policy. Unless you turn it off, every document ends with a notice stating that it is an automatically generated template that must be reviewed by a qualified professional before publication, with the generation date and the plugin version. Bodies and variables can be replaced through the `owc_policy_template`, `owc_policy_vars` and `owc_policy_html` filters.

The tracker table states what the sweep behind it actually covered, in the document's own language: whether a sweep has ever run, whether the last one could read no page at all, whether it was interrupted and after how many pages, how many pages could not be fetched, whether any browser has ever reported what the pages load at runtime, and the window beyond which a tracker no longer seen is dropped from the list. A finding the blocker cannot suppress — a cookie set by a `Set-Cookie` response header — is marked as such and the clause claiming that unclassified trackers are set only after consent carries the matching exception. An incomplete sweep is disclosed rather than published as a finished inventory.

= How do I know what the scanner's findings actually cover? =

Once enabled, the scanner runs on the WP-Cron schedule you choose — hourly, twice daily, daily or weekly (weekly by default). A sweep fetches a sample of your own public pages server-side (25 by default) and records third-party scripts, inline tracking signatures, iframes, stylesheets, tracking pixels and the cookies named in `Set-Cookie` response headers. Each finding records where it came from and whether the blocker can actually act on it, and can be classified into the blocking catalogue in one click.

The Scanner screen carries a coverage panel stating what the current findings rest on: whether a sweep has ever run, whether the last one finished or was interrupted by its time budget, how many pages it actually read, how many it could not fetch and the first error returned, and whether a browser has ever reported back through the probe. It is built from the stored status of the last sweep, so it is as accurate after a scheduled run as after a manual one, and a run that reached no page is shown as a failure rather than as a clean result. That is the panel to read before publishing a cookie policy from these findings.

= Which Google Consent Mode v2 signals are emitted? =

All seven: `ad_storage`, `ad_user_data`, `ad_personalization`, `analytics_storage`, `functionality_storage`, `personalization_storage` and `security_storage`. The six consent categories (necessary, functional, analytics, marketing, preferences, social) are mapped to them from a single source of truth, so the banner, the blocker and the page bootstrap cannot disagree. The default call is printed as static markup and the update is computed in the browser, which keeps the page cacheable.

= What does the IAB TCF v2.2 module do? =

It exposes `__tcfapi`, the `__tcfapiLocator` iframe and a TC String encoder, and writes the `euconsent-v2` cookie only after the visitor has acted — never on a first view. It requires a CMP ID assigned to you by IAB Europe: with no CMP ID the module refuses to load at all, rather than emitting a string carrying CMP ID 0, and it refuses to invent a vendor-list version when the Global Vendor List has not been cached yet. The vendor list is stored in a transient, refreshed by a daily cron job, kept for a week, and served stale rather than re-fetched when a refresh fails.

When the module is enabled, the preferences panel shows one switch per TCF purpose the string can actually claim on this site and one checkbox per special feature the publisher has declared, so nothing is encoded into the string that was not displayed. A special feature stays off unless it is ticked; "Accept all" does not turn it on.

= How are the CCPA opt-out and Global Privacy Control handled? =

A "Do Not Sell or Share My Personal Information" control is injected in the footer, or placed with the `[owc_dnsmpi]` shortcode. The first click records the opt-out directly, as California Regs 7026(a)(1) requires, rather than merely opening the preferences panel. On a site configured under a US profile the control is visible for everyone, with no JavaScript involved. When geo routing is enabled, the same markup is served to every visitor and revealed in the browser for those whose own jurisdiction requires it, so a mandatory link is never decided by whichever visitor happened to populate the page cache. Sites with geo routing off render nothing extra.

The Global Privacy Control signal (the `Sec-GPC` header and `navigator.globalPrivacyControl`) is treated as a binding universal opt-out under the CCPA and US state profiles: the opt-out is recorded once per browser session, and the visitor is told when it replaced a choice they had previously saved. Under GDPR-family profiles GPC is treated as a hint — optional categories are pre-denied in the interface and the banner is still shown — because consent there must be an affirmative act.

= How does geo-based profile routing work? =

It is optional and off by default. When enabled, the visitor's country is resolved from Cloudflare `CF-IPCountry`, CloudFront `CloudFront-Viewer-Country`, a server-set `GEOIP_COUNTRY_CODE`, or a local MaxMind GeoLite2 country database read by a reader bundled with the plugin (no Composer package and no vendor directory required). Proxy headers are trusted only when the site declares the proxy with `OWC_BEHIND_CLOUDFLARE`, `OWC_BEHIND_CLOUDFRONT` or `OWC_GEO_TRUSTED_HEADERS`; without a trusted source and without a database, detection deliberately fails closed to the strictest opt-in profile. The IP address is used for the lookup and immediately discarded — never stored, never logged.

Geo routing does not make the HTML vary. The page carries both consent models — the opt-in defaults and the opt-out defaults — and the browser selects the one matching the visitor's jurisdiction before the first Consent Mode default call is emitted. The country itself is resolved on a request that reaches PHP and cached in `owc_geo` for 24 hours, so a visitor whose very first page is served by the cache is treated under the site's configured profile until that cookie exists.

= Which compliance profiles are available? =

Eleven: GDPR, UK PECR, Swiss nFADP, LGPD, POPIA, PIPL, DPDP, Quebec Law 25, CCPA, Australian Privacy Act and a generic US state profile. The profile drives the consent model (opt-in, or opt-out for CCPA, the generic US profile and Australia), the Consent Mode defaults, whether the "Do Not Sell or Share" control is shown, whether GPC is binding, which privacy notice and cookie policy are generated, which rights are listed and which supervisory authority is named.

= Which cookies does the plugin set itself? =

All of them are first-party, none of them is used for tracking or analytics, and they are declared in the generated cookie policy.

* `owc_consent` — the visitor's choices, their pseudonymous browser token and the profile in force. Lifetime follows the renewal setting (12 months by default, capped at 13). A renewal setting of 0 is treated as that 13-month maximum, not as "never expires", and the same figure governs the cookie, the server-side expiry check and the duration the generated documents publish.
* `owc_geo` — the detected country and the resulting profile, for 24 hours. Written only when geo routing is enabled and a country was actually detected. It contains no IP address.
* `owc_gpc` — a session marker recording that the Global Privacy Control signal has already been processed for this browser, so the signal is not re-recorded on every page view.
* `owc_gpc_notice` — five minutes, so the interface can tell the visitor that GPC replaced the choice they had saved.
* `euconsent-v2` — the IAB TCF consent string, written only when the TCF module is enabled and only after the visitor has acted.

= What happens to my data if I delete the plugin? =

The consent ledger, the scanner findings, the tracker catalogue, the OW Forms consent links, the options and the settings survive deactivation and deletion by default. Two things are removed whatever the "Delete all data on uninstall" setting says.

The scheduled jobs are always unscheduled, so deleting the plugin cannot leave a cron event pointing at code that is gone. And the data-subject request register — the names, e-mail addresses and free-text messages people sent you, plus its rate-limiting transients — is always dropped. It is the only table holding directly identifying personal data about other people; once the plugin is gone nothing enforces the retention period that bounded it, and no screen is left that could read, answer, export or erase it. Export the requests you need to keep from the DSAR screen before you delete the plugin.

For a clean removal of everything else, enable "Delete all data on uninstall" before deleting the plugin — it then drops the plugin's tables, options and transients, on every site of a multisite network.

== Changelog ==

= 1.4.3 =

**Recording a consent**

* The consent cookie is no longer written percent-encoded twice. The value was already encoded when it was built and was then handed to `setcookie()`, which encodes its value a second time, so `%257B%2522v%2522…` went on the wire instead of `%7B%22v%22…`. PHP was unaffected — it decodes once on the way in and the plugin decoded once more, so the server, the register and the state endpoint all read a valid signed consent — which is why every server-side check passed while browsers saw nothing. The write now uses `setrawcookie()`, and one encoding travels where two used to.
* The cookie readers in the page bootstrap, the tracker blocker and the TCF module decode until the value stops changing, bounded to three passes. This is what the 1.4.2 entry below described; it is in place from this release. A cookie written before it is still read, so visitors who had already made a choice are not asked again.
* The same double encoding affected the regional-detection cookie, which the browser then discarded: every visitor fell back to the configured compliance profile whatever their jurisdiction. Fixed the same way.
* Verified end to end rather than by inspection: a harness drives a real consent write through the plugin over HTTP, takes the cookie off the wire and runs the page bootstrap over it, on PHP 7.4 and 8.3, for an acceptance and a refusal, with and without a linked policy page.

= 1.4.2 =

**Recording a consent**

* Reading the consent cookie no longer fails on a cookie that is present and valid. The value reaches `document.cookie` percent-encoded twice — once when written, once by the cookie layer — while the reader decoded it exactly once, so `JSON.parse()` was handed `%7B%22v%22...` and threw. The reader now decodes until the value stops changing, bounded to three passes.
* That parsing failure had been latent since the reader was written: it only drove banner visibility, where a null reads as "no choice yet". In 1.4.1 the write path began treating the same null as "the browser kept nothing", so every visitor who accepted or refused was shown an error and the banner did not close — for a choice the server had recorded. Sites that installed 1.4.1 should update.

= 1.4.1 =

**Recording a consent**

* A choice the server accepted could still be lost by the browser, and nothing said so. The banner called the REST endpoint at the absolute address `rest_url()` builds from the Site Address setting; on a site whose visitors browse another host — www against apex, an alias domain, a reverse proxy — that address is cross-origin, and a cross-origin request sent with `credentials: 'same-origin'` neither sends cookies nor stores the `Set-Cookie` that comes back. WordPress still answered 200, the register row was written and the script read a clean success, while the browser kept nothing: the banner returned on the very next page as though no choice had been made.
* The banner now re-anchors that call on the origin actually being browsed, keeping the path PHP supplied — which already carries `?rest_route=`, a subdirectory install or a custom REST prefix, none of which `/wp-json` can be assumed to stand for. A request to the page's own origin is same-origin by construction, so the cookie is sent and stored whatever the Site Address holds. Correcting that setting remains the real cure; the banner no longer depends on it.
* When a write the server called successful leaves the browser holding no readable consent cookie, the condition is carried on the next write and recorded as `owc_cookie_not_persisted`. It was previously known only to the visitor who hit it.

**Dashboard**

* That condition is reported apart from refusals, because the choice *was* saved — it is not a refusal — and shown with the same declared-host against browsed-host comparison, which is where the cause usually lies.
* The visitor is told what happened rather than shown a generic failure: their choice reached the site but their browser did not keep the cookie recording it.

= 1.4.0 =

**Recording a consent**

* Public write endpoints no longer depend on a core nonce. A nonce is tied to a session and is baked into the HTML a full-page cache stores, so the token served to one visitor was already spent or foreign for the next, and their choice was refused. The write routes now carry their own token, verified independently of the core nonce.
* The site's own host is reconciled with the address the request actually arrived on. A site reachable on a spelling WordPress is not configured with had every consent write rejected by the same-origin check.
* Refused writes are recorded. A refusal that the visitor sees as an error left no trace an administrator could find; the cause, the host presented and the time are now kept.

**Dashboard**

* A panel reports when choices are not being recorded: how many were refused, over what period, the last one, and a comparison between the declared host and the one the requests arrive on, so the cause is named rather than guessed. It disappears on its own once writes go through again, and can be cleared once the cause is addressed.

= 1.3.2 =

**Packaging**

* The three `phpcs:ignore` annotations added in 1.3.1 are moved onto the line immediately above the statement they cover. An annotation applies to the next line only, so a comment sitting between it and the query silenced nothing — the notices it was meant to close were still raised. No executable code changed: the queries themselves are byte-identical to 1.3.1.
* `PreparedSQLPlaceholders.ReplacementsWrongNumber` is added to those annotations. The placeholder run and the argument list are both sized from the same `count()` a few lines above and cannot drift, but neither is countable at analysis time, so the notice is a false positive that had to be stated rather than chased.
* Translation catalogues regenerated.

= 1.3.1 =

**Packaging**

* Three queries are rewritten so a static analyser can check them, with no change to what they do. Two pass their arguments to `wpdb::prepare()` spread rather than as one array — `prepare()` unwraps a single array itself, so both forms run identically, but only the spread lets PHPCS count the arguments against the placeholders instead of reporting one replacement for two markers.
* The orphan sweep on the form-link table no longer builds an `IN()` list. The list held nothing but ids this same function had just read out of that table, yet it remained the last SQL fragment assembled by string building, and Plugin Check's UnescapedDBParameter sniff does not honour a `phpcs:ignore`. It now issues one fully bound delete per row: a cron-time pass, capped at the prune batch size, on a primary key.

= 1.3.0 =

**Requirements**

* WordPress 6.2 is now the minimum, raised from 6.0. Every table name in the plugin's SQL is bound with the `%i` identifier placeholder `wpdb::prepare()` gained in 6.2, instead of being written into the query text. Sites running 6.0 or 6.1 keep 1.2.0 and are not offered the update.

**Recording a consent**

* A choice this site cannot record is now refused instead of being reported as saved. When output has already been sent and the consent cookie can no longer be written, the endpoint answers 503 and stores nothing, rather than signing a register row for a cookie that never existed.
* A register write the database refuses now answers 503 as well, and the consent cookie set a moment earlier is withdrawn. 1.2.0 answered 200 and reported the failure in a field the browser discarded: the trackers were released and nothing evidenced the consent.
* The banner no longer reads any 200 as a recorded consent. A cache interstitial, an edge challenge or a firewall page returned with status 200 is now treated as a failure — success requires the endpoint's own reply — instead of closing the banner and pushing a Consent Mode update for a write that never happened.
* Failed writes are told apart in what the visitor reads: a dead connection, a refused origin, a rate limit, an unreadable reply, a timeout, a browser without `fetch` and a missing endpoint each have their own message, where all of them previously read as one generic error. The HTTP status and the server's own error code are put on the element and logged once, so a support screenshot names the cause.
* The `www.` and apex spellings of the site's own host are accepted as the same site by the same-origin check on the public write endpoints. A site reachable on both, while the options table names only one, could otherwise have every consent write refused. Any other alias — a staging domain, a headless front end — still has to be declared through the `owc_allowed_request_hosts` filter.
* The banner's close control is now in the markup on every site, carrying `hidden` where the setting is off. When a refusal cannot be recorded, it is revealed and relabelled so the visitor can put the notice away, without that dismissal being counted as a choice that was never stored. Where the setting is on, closing still records a refusal of every optional category, as before.

**Detection and blocking**

* Classifying scanner findings into the blocking catalogue now drops the blocker's cached catalogue. The rules were written but the blocker kept serving its cached copy for up to twelve hours, so a finished sweep could classify a tracker and still leave it unblocked on the next page view.
* The two `ini_set()` calls that raised `pcre.backtrack_limit` to five million for the blocker's rewrite pass are removed. They changed a PHP setting for every other plugin in the same request, and they ran catalogue patterns at five times the budget the plugin's own pattern validator certifies them against, so a pattern accepted as safe was then executed with that limit moved. The engine default is kept; a page PCRE cannot finish is still shipped unmodified rather than blank.

**Packaging**

* The thirteen errors in the official Plugin Check report are closed: escaping on the dashboard counters, the missing translator comments, the packaged translation file, the distribution manifest and the last unprepared query. The direct-query notices that remain are the ones a plugin owning its own tables is expected to carry, and each states on its own line why it stands.

= 1.2.0 =

**Architecture**

* The front end is now cache-independent: the rendered HTML no longer varies with the visitor's consent cookie, the banner is no longer hidden server-side, and the consent state is rebuilt in the browser before first paint. Sites behind a full-page cache no longer serve one visitor's consent to another.
* No nonce is printed in cacheable HTML any more. Writes fetch a fresh nonce from a new no-store `owc/v1/nonce` endpoint, and retry once when it has expired.
* The tracker catalogue moved out of PHP into `data/tracker-catalog.json` (175 signatures: 110 URL patterns and 65 cookie names), loaded through the new `owc_scripts_catalog` filter.
* Classes are autoloaded. The legal document generator, the data-subject request module, the GeoLite2 reader and the admin screens are no longer parsed on requests that never use them, which keeps more than half of the plugin's PHP off a front-end page view. The banner stylesheet is no longer enqueued when the banner is disabled, and the Global Privacy Control styles ship only when that signal is honoured.

**Security**

* Rate limiting on the public REST endpoints, keyed on a hashed client address: consent writes, data-subject requests (per address, per target e-mail and site-wide) and the vendor-list route. IPv6 addresses are grouped to a /64. Behind a CDN or reverse proxy, declare it with `OWC_TRUSTED_PROXY` so the limit keys on the real client rather than on the proxy.
* The consent cookie carries a MAC. A payload without a valid signature is discarded rather than read, so a forged cookie can no longer stand in for a choice the visitor never made.
* Same-origin checks and strict per-argument validation on every public route; `dsar_enabled` is now enforced server-side.
* DSAR verification tokens are 64 characters, stored only as a SHA-256 hash, compared in constant time, single-use and expiring. Confirming a request now requires an explicit action on the verification page, so a mail scanner following the link can no longer confirm an identity.
* Fulfilling a data-subject request is refused on the server, not merely hidden in the interface, while its author has never confirmed their identity: the portability export, the erasure run and closing a request as honoured all check first.
* Ledger writes run inside a transaction with a row lock, the canonical form is length-prefixed, and a failed write is reported instead of being silently swallowed. The re-signing path that could have been used to rewrite history has been removed.
* The ledger's key source is detected and recorded: when the WordPress salts live in the database rather than in `wp-config.php`, an admin notice states that the chain is not tamper-evident. A dedicated `OWC_LEDGER_KEY` constant is now supported.
* Ledger rows sealed before 1.2.0 with an unkeyed digest are counted separately by the verifier and reported as a chain break: a digest anyone with database access can recompute is not evidence, and the chain is not declared intact while such rows remain.
* Geo detection no longer trusts proxy country headers unless the site declares the proxy, the `owc_geo` cookie is validated, and the profile is always recomputed server-side, so a visitor can no longer choose their own legal regime.
* CSV and JSON exports escape formula-injection prefixes, and stream instead of building the whole file in memory.
* Every file-level `phpcs:disable` was removed and replaced with narrow, justified per-line exceptions.

**Compliance**

* Data-subject requests are now genuinely fulfilled: OW Consent registers WordPress's personal-data exporter and eraser hooks, the DSAR screen downloads a JSON portability bundle, runs the erasers, records what was removed and what was retained, sends deadline reminders and supports the Article 12.3 two-month extension. Previously the dashboard only changed a status flag.
* The privacy notice and the cookie policy are now written per jurisdiction — POPIA, PIPL, DPDP, nFADP, the Australian Privacy Act, the US state statutes, UK GDPR/PECR, LGPD and Quebec Law 25 each have their own document, in their own language, instead of a French GDPR document with a different label. Generation is refused, with an explicit error, where no appropriate template exists.
* Generation is blocked and lists the mandatory Legal identity fields that are still empty, instead of publishing blank clauses. New settings cover the company's legal form, registration and VAT numbers, publication director, host, regulated profession, consumer mediator, EU representative, transfers, automated decision-making, the age of digital consent and retention periods.
* Unverifiable assertions were removed from the generated documents (processor agreements, encryption, backups, impact assessments, PCI-DSS), the defunct EU ODR platform reference was removed, and the model withdrawal form, both statutory warranties, the delivery clause and the mediation clause were added to the terms.
* The consent ledger's retention period is now enforced by a daily job that prunes old rows and re-anchors the chain, so the retention the documents announce is the one actually applied. The page the consent was given on is recorded from the request itself instead of the `Referer` header, and a provenance that cannot be attributed to this site is recorded as none rather than as a foreign URL.
* The close cross, when enabled, records a refusal; the light and dark themes and the optional logo, previously inert settings, now work; the consent cookie lifetime follows the renewal setting, capped at 13 months. A renewal of 0 now means that 13-month maximum everywhere — in the cookie, in the server-side expiry check, in the browser runtime and in the published documents — instead of meaning "never" in one place and 13 months in another.
* The banner's first layer links to a privacy policy (falling back to the one WordPress already knows) and names the data controller, so a freshly activated plugin no longer shows a consent request with no route to any information. The Necessary category now names the plugin's own cookies and their lifetimes.
* The Global Privacy Control signal is treated as a binding opt-out only in the jurisdictions that recognise it, and the "Do Not Sell or Share" link records the opt-out on the first click. The link is now served in identical markup to every visitor and revealed in the browser where the visitor's own jurisdiction requires it, so a page cache can no longer decide whether a mandatory control exists.
* Google Consent Mode: ad-identifier redaction and URL passthrough before consent are now operator settings with their own controls, instead of being hardwired on.
* IAB TCF: the module refuses to emit anything without a real CMP ID, no longer invents a vendor-list version, writes `euconsent-v2` only after the visitor has acted, corrects the purpose mapping and encodes real vendor consent sets. The panel now shows one switch per purpose the string can claim and one checkbox per declared special feature, so nothing is encoded that was not displayed; special features stay off unless ticked. The vendor list is refreshed by a daily cron job and served stale on failure, instead of being re-fetched on every page view.
* Generated terms of sale distinguish a site that sells online from one that works on quotation, so the checkout, price and statutory-warranty clauses are no longer written about goods on a site that sells none, and Belgian sites now cite the Belgian Code de droit économique and the federal consumer mediation service instead of French articles.

**Detection and blocking**

* Cookie-name signatures can no longer match a URL, and short signatures such as `fr` or `IDE` can no longer match inside unrelated file names.
* Blocking now covers tracking pixels, `noscript` content, resource hints to catalogued third parties, analytics and marketing stylesheets, media sources and lazy-loading attributes; the runtime re-injects released scripts in document order and re-scans content added after load. A host the catalogue classes as necessary is returned untouched, and stylesheets from hosts classed as functional are left in place, so blocking neither breaks a page nor strips its typography.
* A `javascript:` script source is decoded and classified like an inline snippet, instead of only `data:` being read. The browser-side guard against scripts injected after load now honours the host allowlist as the server does, matches on host and path only — so a first-party URL that merely mentions a tracker domain in its query string is no longer refused — and restores a refused `type=module` script as a module rather than as classic JavaScript.
* The scanner reads `Set-Cookie` response headers, records the provenance of each finding, resumes an interrupted sweep and honours the configured frequency. Findings can be classified into the blocking catalogue in one click, and a category set by hand is remembered separately from a category the catalogue matched, so the next sweep no longer overwrites the administrator's decision.
* A sweep that finishes without reading a single page, or that fails part of its pages, now e-mails the administrator once — the previous failure mode was silence, because a sweep that read nothing found nothing to report.
* A server-side sweep cannot see a cookie written by JavaScript. An in-page probe covers that gap by observing what actually runs in a browser, and it runs only in a logged-in administrator's own session, never a visitor's. It can be switched off on the Scanner screen, in which case the tracker list reflects HTML inspection alone.

**Admin**

* New Legal identity and Tracker catalogue tabs; every setting the plugin reads now has a control, and the settings that were rendered but never read now do something.
* The Scanner tab states what the current findings actually rest on — whether a sweep has ever run, whether the last one finished, how many pages it read, how many it could not fetch and the error returned, and whether a browser has ever reported back through the in-page probe. It is built from the stored status, so a scheduled run is reported as faithfully as a manual one.
* The compliance diagnostic gained three checks: no privacy policy linked anywhere, no cookie policy linked, and a supervisory authority typed by hand that the active jurisdiction contradicts — the last with a one-click reset back to the derived one.
* Text and description fields now show the stored value and offer the translated default as a placeholder, so an empty field visibly means "using the translated default" and editing one in French no longer freezes French into the database for every language.
* The audit ledger, the DSAR queue, the scanner findings and the catalogue gained search, filters, pagination and CSV/JSON export; ledger verification reports every break rather than only the first, names the unkeyed-row case rather than calling it unrecognised, and counts the rows it could not verify. The ledger REST route accepts a visitor token and a date range, so answering an access request no longer means paging the whole chain.
* The Policies tab flags a page whose manual edits a regeneration replaced, and links to the revision that holds them.
* Accessibility: `role="switch"` toggles with a visible state, focus trapping and restoration, an accessible close control, corrected contrast, and support for reduced motion, forced colours and print.

**Compatibility and lifecycle**

* Multisite: tables are provisioned per site, activation and deactivation cover the network, and new sites are provisioned as they are created.
* Schema migrations are deferred off the front-end request path and serialised with a lock; an `uninstall.php` gated on an explicit setting removes the five plugin tables, the options and the transients.
* Deleting the plugin now always drops the data-subject request register — the subject names, e-mail addresses and free-text messages — whatever the data-deletion setting says. It is the only table holding directly identifying personal data about other people, nothing bounds its retention once the code that prunes it is gone, and no screen would be left to answer or erase it. The consent ledger and everything else still follow the setting; export the requests you need to keep before deleting.
* All six of the plugin's scheduled jobs are unscheduled on deactivation, across the network, and again on deletion — the latter whether or not you enabled the data-deletion setting, so removing the plugin never leaves an orphaned cron event behind.
* MaxMind GeoLite2 support no longer requires a Composer library: a dependency-free reader is bundled.
* OW Forms integration implemented: submissions are linked to the consent in force, and erasure requests cascade to them.
* `load_plugin_textdomain()` restored on `init`, registered from a single place, and translated strings are resolved at render time instead of being frozen into the database at activation, so switching the site language now switches the banner.
* A French (fr_FR) translation ships with the plugin. All 986 strings the plugin exposes to translators are translated, including the 40 that have singular and plural forms. It is compiled both as `languages/ow-consent-fr_FR.l10n.php`, the format WordPress 6.5 and later loads first, and as `languages/ow-consent-fr_FR.mo` for earlier versions; the `.po` is kept alongside them as editable source, and `languages/ow-consent.pot` remains the template for other locales.

= 1.1.0 =

* Initial public release.
* Hardening pass for WordPress.org submission: SQL preparation, input sanitisation, output escaping and nonce verification across the admin and REST surfaces.
* Heredoc syntax replaced with standard string concatenation.
* i18n source language is English; `languages/ow-consent.pot` is included for translators.

== Upgrade Notice ==

= 1.4.2 =

Fixes 1.4.1 showing an error and leaving the banner open after a choice the site had in fact recorded. Update if you installed 1.4.1.

= 1.4.1 =

Fixes a choice the server accepted being silently dropped by the browser on sites whose visitors browse a host other than the one WordPress is configured with; the banner came back on the next page as if nothing had been chosen. The Dashboard now reports that case separately from a refusal.

= 1.4.0 =

Fixes consent writes being refused outright on sites reached on an address WordPress is not configured with, or served from a cache. If any write is still refused, the Dashboard now names the cause and what to change. After updating, load a page and use the banner to confirm a choice is recorded.

= 1.3.0 =

Requires WordPress 6.2 or later. A consent write that failed could be reported as recorded, and a newly classified tracker stayed unblocked until the catalogue cache expired. After updating, open a page as a visitor and check that a choice made on the banner is recorded.

= 1.2.0 =

Security and correctness release. Regenerate and re-review your policy pages: documents now come from new templates and the Legal identity tab. Cookies written before 1.2.0 are unsigned and no longer accepted, so those visitors are asked once more.
