Consent-sync fix: returning visitors get no Google consent update

Status: PREPARED, NOT APPLIED. Nothing in this document has been done on either live site. It waits for Dries’s approval. Written 4 Oct 2026.

The defect, in one paragraph

A returning visitor who accepted cookies more than 30 days ago gets no gtag('consent','update',…) on page load. Every GA4 and Ads hit then goes out as gcs=G100 with a fresh client id. The cause is two cookies that should agree but expire at different times:

  • Complianz stores the choice in cmplz_<category> cookies, valid for 365 days ("cookie_expiry":"365").
  • WP Consent API keeps its own copy in wp_consent_<category> cookies, valid for 30 days (consent_api = {"cookie_expiration":"30", "cookie_prefix":"wp_consent", …}).
  • Site Kit decides whether to push a consent update by calling wp_has_consent(category), which reads only the wp_consent_* cookie.
  • Complianz writes wp_consent_* only when its own cookie changes. In complianz.min.js 7.5.5, cmplz_wp_set_consent has exactly one call site, inside cmplz_set_consent, guarded by n!==t (the old value differs from the new one). Nothing re-syncs it on a later page load.

So on day 31 cmplz_has_consent('statistics') is still true, wp_has_consent('statistics') is false, and Site Kit stays silent.

Confirmed in Dries’s Chrome on 4 Oct 2026:

  • .be: cmplz_has_consent('statistics') = true, wp_has_consent('statistics') = false, and there are no wp_consent_* cookies.
  • .com: both are true and wp_consent_* exists. That accept is younger than 30 days.

.com will break the same way once its wp_consent_* cookies turn 30 days old. The fix goes on both sites.

Script order is not the cause. Both sites serve the consent default first, then Site Kit consent-mode, then WP Consent API, then Complianz (deferred), and the three JS files are byte-identical across the sites (md5 checked 4 Oct).

The snippet (identical on both sites)

<script>
/* Consent sync: copy an EXISTING Complianz "allow" into WP Consent API (and so into Site Kit's
   Google consent update). Never grants anything Complianz does not already hold; never denies.
   Why: wp_consent_* cookies live 30 days, cmplz_* 365, and Complianz only re-writes wp_consent_*
   when its own choice changes. See docs/consent-sync-fix.md. */
(function () {
  var CATEGORIES = ['preferences', 'statistics', 'marketing'];
  // The visitor's stored banner choice, read from the cookie itself.
  // cmplz_has_consent() alone is not enough: it returns true for bots and
  // always for 'functional', without any choice having been made.
  function storedAllow(c) {
    var prefix = (window.complianz && window.complianz.prefix) || 'cmplz_';
    return document.cookie.split(';').some(function (p) {
      return p.trim() === prefix + c + '=allow';
    });
  }
  function sync() {
    if (typeof window.cmplz_has_consent !== 'function' ||
        typeof window.wp_has_consent !== 'function' ||
        typeof window.wp_set_consent !== 'function') {
      return; // a library is missing: do nothing rather than guess
    }
    CATEGORIES.forEach(function (c) {
      if (storedAllow(c) && window.cmplz_has_consent(c) === true &&
          !window.wp_has_consent(c)) {
        window.wp_set_consent(c, 'allow');
      }
    });
  }
  // Normal path: Complianz has applied the stored choice to the page.
  document.addEventListener('cmplz_fire_categories', sync);
  // Late-load path: this snippet ran after Complianz had already initialised
  // (wp_consent_type is set inside Complianz's init, just before it fires the event above).
  if (window.wp_consent_type) sync();
})();
</script>

Why it is safe

  • It never grants. It calls wp_set_consent(c, 'allow') only when both of these hold:
    • the Complianz cookie cmplz_<c> literally reads allow, which is the choice the visitor made in the banner;
    • cmplz_has_consent(c) === true, which also respects Do Not Track, enabled on .com.

    A visitor with no Complianz cookie, or one who denied, gets nothing. The cookie check is there because cmplz_has_consent alone returns true for bots (cmplz_is_bot()) and always for functional, even when no choice has been made. Without that check the snippet would grant on a first visit.

  • It never denies. Mirroring a deny is left to Complianz, which already does it on change. (Decision I made: the request said “never grant”; I also kept it out of the deny path so the snippet can only ever restore Complianz’s own state, never invent one.)
  • functional is deliberately left out. Complianz treats it as always allowed, so it carries no visitor choice to restore. Including it would grant functionality_storage / security_storage to visitors who never answered the banner. (Decision flagged: Complianz’s own first-accept path does write wp_consent_functional=allow, so after the fix a long-returning visitor gets those two types back only when they next change a choice. This does not affect GA4 or Ads.)
  • It is idempotent. Once the wp_consent_* cookie exists, wp_has_consent is true and the snippet does nothing until the cookie expires again 30 days later. Then it repairs it again.

Guard checked against fakes (4 Oct 2026, Node, before any live change)

The snippet was syntax-checked with node --check. It was then run against a fake window/document that models cmplz_has_consent, including the bot and functional shortcuts. Calls recorded to wp_set_consent:

first visit, no choice                     []
bot, no choice                             []
denied all                                 []
stats allow, mkt deny, wp expired          ["statistics=allow"]
all allow, wp expired (the .be case)       ["preferences=allow","statistics=allow","marketing=allow"]
all allow, wp present (the .com case)      []
marketing withdrawn, wp deny               []

This is a model of the libraries, not the libraries themselves. The live check is the Verification section below.

How it reaches Google, and the timing caveat

  1. wp_set_consent(c,'allow') writes wp_consent_<c>=allow and, because the value changed, dispatches wp_listen_for_consent_change.
  2. Site Kit’s consent-mode script listens for that event and calls gtag('consent','update',{analytics_storage:'granted', …}), using _googlesitekitConsentCategoryMap to map categories to consent types.

Order on the page. WPCode’s footer output is an inline script inside wp_footer. It runs while the page is parsed, before the deferred complianz.min.js executes. Complianz’s init is top level and synchronous on both sites ("geoip":"", so no XHR). The listener is therefore always registered before cmplz_fire_categories fires. wp_set_consent is already defined by then, because wp-consent-api.js is a normal (non-deferred) script earlier in the page. The if (window.wp_consent_type) sync(); line covers the opposite order, for example if an optimiser ever moves the snippet after Complianz.

Residual risk, same as .com today. The update lands when the deferred Complianz script has run, just before DOMContentLoaded. The consent default carries wait_for_update: 500, so gtag holds hits for up to 500 ms.

  • On a slow page: if Complianz takes longer than that, the first page_view can still leave as G100, and later hits are G111.
  • No worse than .com: this is exactly where Site Kit’s own on-load check already runs on .com.
  • Not fixable by this snippet: it cannot be fixed without moving consent ahead of gtag.js altogether, which is a larger change and out of scope here.

Install route per site

Neither site can take this without wp-admin. This was checked rather than assumed:

  • WPCode has no WP-CLI command and no REST endpoint. WPCode Lite 2.3.9, downloaded from wordpress.org on 4 Oct 2026:
    • grep -r WP_CLI over the plugin finds nothing.
    • register_rest_route finds nothing.
    • The wpcode post type is registered public => false, show_ui => false with no show_in_rest.
    • It also blocks wp_insert_post writes to that type from callers without its capabilities (wpcode_block_unauthorized_snippet_writes).
  • A wp post create --post_type=wpcode would therefore skip WPCode’s own save path. That path covers the type, location and active meta, plus the snippet cache. Do not do it.

secondaire.verbomatique.be (Combell, self-hosted) — wp-admin

  • SSH and WP-CLI are not usable. SSH is active but the only registered key (rsa-key-20160719) is not on the Mac (knowledge file, “The Combell hosting, read properly”, 14 Sept 2026). Even with a key, see above: WPCode has no CLI.
  • WPCode is installed: insert-headers-and-footers, among the 9 active plugins.

Steps:

  1. secondaire.verbomatique.be/wp-admin/ → Code Snippets → + Add Snippet → Add Your Custom Code (New Snippet).
  2. Code type HTML Snippet, so the <script> tags above go in as written.
  3. Title: Consent sync: Complianz → WP Consent API (returning visitors).
  4. Insertion: Auto Insert, location Site Wide Footer.
  5. Toggle Active, then Save Snippet. Note the snippet id it gets.
  6. There is no page-cache plugin on .be (the plugin list holds none), so there is nothing to purge.

verbomatique.com (WordPress.com Business, Atomic) — wp-admin

  • WPCode is installed and active there: WPCode Lite 2.3.9, insert-headers-and-footers/ihaf, status active. This was read live via the WordPress.com connector, plugin.list, on 4 Oct 2026.
  • “Code Snippets” is not installed. The knowledge file’s en-CA plugin line, which lists it, is stale.
  • The connector cannot add a WPCode snippet. Its plugin tool installs and activates plugins; it does not write plugin data. Its content tools write posts, pages and templates, not the hidden wpcode type. Plain REST cannot do it either (see above).

Steps:

  1. Run the same steps 1–5 in verbomatique.com/wp-admin/.
  2. Afterwards, check the served HTML is not altered by Page Optimize, which concatenates enqueued scripts into /_static/??…. An inline WPCode footer script should pass through untouched; the verification below confirms it.
  3. The WordPress.com edge cache (x-ac … MISS/HIT) may serve the old HTML for a short while. If the verification curl does not show the snippet, use Hosting → Performance → Clear cache or wait.

Verification (what I will run after you apply it)

1. The snippet is served (read-only, from this Mac):

curl -s https://secondaire.verbomatique.be/ | grep -c "Consent sync: copy an EXISTING Complianz"
curl -s https://verbomatique.com/ | grep -c "Consent sync: copy an EXISTING Complianz"

Expect 1 on each. It must appear before the cmplz-cookiebanner-js tag or, if after it, the late-load path covers it.

2. Returning visitor, cookie expired: the case that is broken today. In Dries’s Chrome on .be, which currently has cmplz_* and no wp_consent_*:

  • Reload, then in the console run dataLayer.filter(e => e[0] === 'consent'). Expect the default entry and an update with analytics_storage: 'granted' and ad_storage: 'granted'.
  • wp_has_consent('statistics') should return true, and the wp_consent_statistics=allow cookie should now exist.
  • Network, filtered on collect: region1.google-analytics.com/g/collect should carry gcs=G111.
  • Reload a second time and check the cid= value is the same on both reloads.

3. To reproduce on .com, where the cookie is still valid: in DevTools → Application → Cookies, delete only the wp_consent_* cookies, keep cmplz_*, then repeat step 2.

4. First visit is unchanged. In an incognito window:

  • Before clicking anything: no update in dataLayer, no wp_consent_* cookies, hits at gcs=G100.
  • Deny / Weigeren: still no granted update.
  • Accept: an update granted, as before. This is the existing Complianz path, untouched.

5. Withdrawal still works. After accepting, open the Complianz manage-consent tab and withdraw marketing. wp_consent_marketing=deny should appear, and the snippet must not flip it back on the next reload, because its guard is cmplz_has_consent('marketing') === true.

6. Fast regression signal (inferred, not yet measured): in GA4 → Admin → Data streams → Consent settings, the share of consented hits for nl-BE should rise in the days after the fix.

Rollback

On each site: wp-admin → Code Snippets → the “Consent sync” snippet → toggle Inactive (or Trash it). On .com, also clear the cache as above.

Effect of rollback:

  • Behaviour returns exactly to today’s.
  • The wp_consent_* cookies the snippet wrote stay in visitors’ browsers until they expire.
  • That is harmless: they only mirror an allow Complianz already held, and Complianz overwrites them on any later change of choice.

Not chosen, and why

  • Raising WP Consent API’s cookie lifetime to 365 days. This does not repair visitors whose wp_consent_* cookie has already expired, which is the case seen on .be. It would need a PHP filter on both sites as well, so it is more change for less effect.
  • Replacing Site Kit’s consent mode with Complianz’s own Google Consent Mode integration. It would work, but it changes which component owns consent on two live sites. That is a bigger decision than this defect needs.