Reference

Consent Resolution on Higher-Order Load

A device- and browser-agnostic pattern for resolving consent in any client-side template.

Version 1.0 · 2026-09-04 · Story Story AI

Derived from the www.crm-sync.dev Consent Resolution Report (HAR capture, 2026-09-04) and from the shipping implementation in the CRM Sync stack loader. This document generalises that one observed behaviour into a pattern any template can adopt — Shopify Liquid, Webflow, WordPress, Astro, Next, Nuxt, 11ty, AEM/EDS, Salesforce Experience, or a hand-written index.html.


1. The problem this solves

A consent banner tells you what a visitor chose once. It does not tell you what state the page is in on the next load, and that gap is where almost every consent defect lives.

The HAR capture made the failure and the fix visible in the same session:

MomentgcsClient IDWhat it means
Session 1, form_startG110362792477… — temporaryAnalytics denied. Cookieless ping. No persistent identity.
Session 2, after reloadG1111373221016… — persistent, first issued July 2026Analytics granted. The real _ga cookie was readable all along.

The visitor had already consented. The _ga cookie was on the device. But the first page load ran at the denied default for its entire lifetime, because nothing replayed the stored choice — the banner only fires on a new save. An entire session of behaviour was measured cookielessly against a temporary identifier, and only recovered afterwards by shipping ga_temp_client_id for server-side stitching.

That recovery is Google's safety net, not a design. The pattern below removes the need for it.

The invariant. Consent must resolve at load, from durable state, before any tag is injected — not at banner interaction. The banner is an input to consent state. It is never the source of it.

<figure style="margin:20px 0;border:1px solid #d4d4d4;border-radius:3px;page-break-inside:avoid;"> <figcaption style="margin:0;padding:9px 14px;border-bottom:1px solid #1a1a1a;font:600 10px/1.4 -apple-system,'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:.09em;text-transform:uppercase;color:#1a1a1a;">Figure 1 &middot; Findings at a glance</figcaption> <div style="padding:14px 14px 4px;"> <div style="font:600 10px/1.4 -apple-system,'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:.07em;text-transform:uppercase;color:#767676;margin-bottom:7px;">Resolves correctly &mdash; the observed chain</div> <pre style="margin:0 0 16px;padding:11px 12px;background:#f7f7f7;border:0;border-left:2px solid #1a1a1a;border-radius:2px;font:8.5px/1.7 'SF Mono',Menlo,Consolas,monospace;color:#1a1a1a;white-space:pre;overflow-x:auto;">Shopify grants ──▶ Consent Mode ──▶ GA4 recovers ──▶ loader writes US region analytics the persistent the ledger has_consent: true denied ▸ granted cid from _ga and /auth/consent-sync on the reload stitches the /signals/ga4-cid cookieless form_start via ga_temp_client_id</pre> <div style="font:600 10px/1.4 -apple-system,'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:.07em;text-transform:uppercase;color:#767676;margin-bottom:2px;">Three that generalise &mdash; and one to confirm</div> <table style="width:100%;border-collapse:collapse;font:8.6px/1.5 -apple-system,'Helvetica Neue',Helvetica,Arial,sans-serif;margin:7px 0 0;"> <thead><tr> <th style="text-align:left;font-weight:600;border-bottom:1px solid #1a1a1a;padding:5px 8px 5px 0;">Observation</th> <th style="text-align:left;font-weight:600;border-bottom:1px solid #1a1a1a;padding:5px 8px 5px 0;width:64px;">Verdict</th> <th style="text-align:left;font-weight:600;border-bottom:1px solid #1a1a1a;padding:5px 0;width:96px;">Generalised in</th> </tr></thead> <tbody> <tr> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;">Ad-side Consent Mode keys granted from the very first hit &mdash; defensible in the US, a pre-consent leak in the EEA</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;">REVIEW</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 0;vertical-align:top;">&sect;6 state model</td> </tr> <tr> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;">Merchant Center badge pulls Google Fonts outside every consent gate</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;">REVIEW</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 0;vertical-align:top;">&sect;6 third parties</td> </tr> <tr> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;"><code style="font:8px/1.4 'SF Mono',Menlo,Consolas,monospace;background:#f4f4f4;padding:1px 3px;border-radius:2px;">/signals/log</code> reflects <code style="font:8px/1.4 'SF Mono',Menlo,Consolas,monospace;background:#f4f4f4;padding:1px 3px;border-radius:2px;">Access-Control-Allow-Origin: null</code> with credentials &mdash; a real CORS hole; needs an allow-list</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 8px 6px 0;vertical-align:top;">FIX</td> <td style="border-bottom:1px solid #e4e4e4;padding:6px 0;vertical-align:top;">&sect;5 sandboxes</td> </tr> <tr> <td style="border-bottom:1px solid #b4b4b4;padding:6px 8px 6px 0;vertical-align:top;">Only OPTIONS preflights reached the HAR &mdash; re-export unsanitized to confirm the POSTs land</td> <td style="border-bottom:1px solid #b4b4b4;padding:6px 8px 6px 0;vertical-align:top;">VERIFY</td> <td style="border-bottom:1px solid #b4b4b4;padding:6px 0;vertical-align:top;">&sect;7 conformance</td> </tr> </tbody> </table> </div> </figure>


2. The load contract

Five phases, strictly ordered. Phases 0–2 are synchronous and must complete inside the first script execution. Phases 3–4 are asynchronous and may complete at any later point in the page's life.

PHASE 0   deny everything                       synchronous, before any tag exists
             |
PHASE 1   replay stored decision                synchronous, from durable client state
             |
PHASE 2   inject measurement tags               tags boot into an already-correct state
             |
PHASE 3   bridge: CMP / platform / region       async; may raise or lower consent
             |
PHASE 4   write to the server ledger            async; identity + audit record

Everything that follows is an elaboration of those five lines.

Phase 0 — Deny before you load

The consent default block must be the first executable statement of the higher-order loader, ahead of any tag, pixel, font, or third-party script. It establishes the state that every later consumer inherits.

Six keys denied; security_storage granted. security_storage is the strictly-necessary bucket — authentication, fraud prevention, CSRF — and denying it is not a privacy improvement, it is a misdeclaration. (The source report's recommendation to "set all seven keys to denied" should be read as all six non-essential keys.)

wait_for_update: 500 gives an asynchronous CMP a half-second window to raise consent before tags commit to the denied path. It is a grace period for Phase 3 only. It is not a substitute for Phase 1, and a template that relies on it in place of a synchronous replay will reproduce exactly the defect in section 1.

Phase 1 — Replay the stored decision, synchronously

This is the phase that is almost universally missing, and the whole point of the pattern.

Read the visitor's persisted decision from durable client state and issue a consent: 'update' — never a second 'default'. 'default' is a declaration of the pre-decision baseline; re-issuing it after a decision exists misrepresents an informed choice as an absence of one, and downstream platforms model the two differently.

The read must be wrapped in try/catch. localStorage does not merely return null under Safari Private Browsing, a blocked-site-data setting, or a sandboxed context — the accessor itself throws. An unguarded read here kills the loader before Phase 2, which fails open on some stacks and silently dark on others. Both are worse than the defect being fixed.

Phase 2 — Inject the tags

Only now. The measurement library boots into a state that is already correct for this visitor, so its very first hit carries the right gcs/gcd and, when analytics is granted, the persistent client identifier read from the existing cookie. No temporary ID. No stitching. No cookieless first session.

Phase 3 — Bridge to the CMP, the commerce platform, and the region

Consent has more than one authority, and they must not be allowed to disagree:

A single resolve() entry point receives a normalised state and fans it out to every authority in one call. Anything that fans out from more than one place will drift.

Phase 4 — Write the ledger

The browser is a cache. The server is the record.

Safari's ITP caps script-written localStorage at seven days of non-interaction; Firefox ETP and Chrome's storage partitioning apply their own eviction. A consent decision that lives only in the browser is a decision you will lose, and losing it is not a measurement inconvenience — it is the loss of the evidence that the decision was ever obtained.

Write the resolved state, the identifier, and a timestamp to a server-side ledger keyed to the authenticated subject. Two rules govern this write:

  1. Bind to the subject, never to the browser. An anonymous decision has no subject to bind to. Park it locally as pending and flush it on login. This is what makes the record defensible.
  2. Write on transitions only. A CMP fires its wrapper on every page load, including when nothing changed. Comparing against the last-written signature before writing is the difference between an audit trail and audit noise — and re-affirmation churn will make a genuine change unfindable in it.

3. Reference implementation

Vendor-neutral, no dependencies, ES5-safe, ~90 lines. It is the same shape as the shipping stack-loader.js, with the product-specific names removed. Paste it into any template's <head>, above every other script.

<script>
(function () {
  // Idempotent: an SPA route change, a duplicated embed, or a CMS block pasted twice
  // must not re-run Phase 0 and wipe a resolved state back to denied.
  if (window.hos && window.hos._init) return;
  var hos = window.hos = window.hos || {};
  hos._init = true;

  var LEDGER = 'https://your-worker.example';   // baked at build time, NOT read from the page
  var STORE_KEY = 'hos_consent';
  var SIG_KEY   = 'hos_consent_sig';

  function store(k, v) { try { if (v === undefined) return localStorage.getItem(k);
                               localStorage.setItem(k, v); } catch (e) { return null; } }

  // ---------- PHASE 0 — deny before anything loads --------------------------
  window.dataLayer = window.dataLayer || [];
  function gtag() { dataLayer.push(arguments); }
  window.gtag = window.gtag || gtag;
  gtag('consent', 'default', {
    ad_storage:              'denied',
    ad_user_data:            'denied',
    ad_personalization:      'denied',
    analytics_storage:       'denied',
    functionality_storage:   'denied',
    personalization_storage: 'denied',
    security_storage:        'granted',   // strictly necessary — never denied
    wait_for_update: 500
  });

  // ---------- PHASE 1 — replay the stored decision, synchronously -----------
  function toConsentMode(d) {
    return {
      analytics_storage:       d.analytics  ? 'granted' : 'denied',
      ad_storage:              d.marketing  ? 'granted' : 'denied',
      ad_user_data:            d.marketing  ? 'granted' : 'denied',
      ad_personalization:      d.marketing  ? 'granted' : 'denied',
      functionality_storage:   d.functional ? 'granted' : 'denied',
      personalization_storage: d.functional ? 'granted' : 'denied',
      security_storage:        'granted'
    };
  }
  try {
    var stored = JSON.parse(store(STORE_KEY) || 'null');
    if (stored) {
      hos._consent = toConsentMode(stored);
      gtag('consent', 'update', hos._consent);   // 'update', never a second 'default'
    }
  } catch (e) {}

  // ---------- PHASE 2 — inject the tags ------------------------------------
  function js(src, onload) {
    var s = document.createElement('script');
    s.src = src; s.defer = true; if (onload) s.onload = onload;
    document.head.appendChild(s); return s;
  }
  hos.measure = function (id) {
    if (!id || hos._mid) return;
    hos._mid = id;
    js('https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id));
    gtag('js', new Date());
    gtag('config', id);
  };

  // ---------- PHASE 3 — the single resolve entry point ----------------------
  // Every authority calls THIS. Nothing calls gtag('consent','update') directly.
  hos.resolve = function (decision, source) {
    var state = toConsentMode(decision);
    hos._consent = state;
    store(STORE_KEY, JSON.stringify(decision));

    gtag('consent', 'update', state);                                  // measurement plane
    dataLayer.push({ event: 'hos_consent_update', consent: state });   // event bus
    if (hos.platformPrivacy) hos.platformPrivacy(decision);            // commerce plane
    hos.ledger(decision, source || 'banner');                          // record plane
  };

  // ---------- PHASE 4 — the ledger, on transitions only --------------------
  hos.ledger = function (decision, source) {
    var sig = (decision.analytics ? '1' : '0') + (decision.marketing ? '1' : '0')
            + (decision.functional ? '1' : '0');
    if (store(SIG_KEY) === sig) return;                     // re-affirmation: not an event

    var token = store('auth_token');
    if (!token) { store('hos_consent_pending', JSON.stringify(decision)); return; }  // park

    fetch(LEDGER + '/consent', {
      method: 'POST', keepalive: true,
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({
        consent_flags: decision,
        method: source,
        consent_id: (window.crypto && crypto.randomUUID)
                      ? crypto.randomUUID() : String(Date.now()),
        timestamp: new Date().toISOString()
      })
    }).then(function () { store(SIG_KEY, sig); }).catch(function () {});
  };

  // Flush a parked anonymous decision the moment a subject exists.
  window.addEventListener('auth-change', function () {
    try {
      var p = JSON.parse(store('hos_consent_pending') || 'null');
      if (p) { hos.ledger(p, 'deferred_login'); localStorage.removeItem('hos_consent_pending'); }
    } catch (e) {}
  });
})();
</script>

Wiring a CMP into it

Any CMP reduces to a category map and one resolve() call. OneTrust, as an example:

<script>
(function () {
  var prev = window.OptanonWrapper;                 // chain, never replace
  window.OptanonWrapper = function () {
    try { if (typeof prev === 'function') prev(); } catch (e) {}
    var g = String(window.OnetrustActiveGroups || '');
    window.hos.resolve({
      analytics:  g.indexOf('C0002') >= 0,
      functional: g.indexOf('C0003') >= 0,
      marketing:  g.indexOf('C0004') >= 0
    }, 'onetrust');
  };
})();
</script>

Chaining rather than replacing the wrapper matters: another integration on the page may already own it, and clobbering it disables their consent handling silently.


4. Template adapters

The loader is unchanged in every case. Only the placement differs.

TemplatePlacementNotes
Plain HTML / 11ty / Astro / HugoFirst <script> in <head> of the base layoutThe reference case. Nothing else required.
Shopify (Liquid theme)Top of layout/theme.liquid, above {{ content_for_header }}content_for_header injects Shopify's own analytics; the default must precede it. Bridge Phase 3 to Shopify.customerPrivacy.setTrackingConsent.
Shopify (Theme App Extension)An app-embed block with target: headMerchant-toggleable. Note that an app embed loads into <head> where DOM-dependent tiers match nothing — keep Phase 0–2 DOM-free, as the reference does.
WebflowSite-wide custom code, Head slot, first blockWebflow's own webflow.js loads late and does not contend.
WordPresswp_head at priority 1Priority is the whole trick. The default is 10, which puts you behind most analytics plugins.
Next.jsnext/script with strategy="beforeInteractive" in the root layoutafterInteractive is too late — tags will have loaded at denied.
Nuxtapp.head.script[0] with tagPosition: 'head'
SvelteKit%sveltekit.head% region of app.html, first entry
AEM / Edge Deliveryhead.html, before scripts.js
Salesforce Experience CloudHead Markup on the site, first block
SPA route changesNothing. Do not re-run the loader.The _init guard exists for this. Consent is per-document, not per-route.

Headless and cross-origin storefronts

When the page is not on the commerce platform's own domain, the platform privacy bridge needs the headless form — a public storefront token plus the checkout and storefront root domains — and it only works when the page shares a registrable domain with checkout. On a foreign domain, cross-zone cookies are impossible and the bridge should be skipped by design rather than attempted and silently failing. Zone detection belongs on the hostname, never on a user-agent string.


5. Why this is device- and browser-agnostic

Nothing in the pattern branches on device, browser, or user agent. That is a deliberate property, and these are the rules that preserve it.

No user-agent inspection, ever. A UA string is trivially spoofed and increasingly frozen. Any consent behaviour that varies by UA is a suggestion, not a boundary. Branch on hostname (which zone am I in), on region signal (does an opt-in mandate apply), and on stored state — never on the client's self-description.

Every storage access is guarded. localStorage, sessionStorage, and IndexedDB accessors throw outright in private modes, sandboxed frames, thumbnail/preview contexts, and under block-site-data settings. Every read and write in the reference is inside try/catch and the page renders correctly when all of them return nothing.

Browser storage is a cache; the server is the record. ITP, ETP, and partitioned storage will evict client state on their own schedules. The pattern degrades to "re-prompt the visitor" — never to "silently treat a consented visitor as unconsented and keep measuring", and never to "lose the evidence of consent".

Cookies are never the source of truth. Consent state is read from the loader's own durable key and from the ledger. Third-party cookie deprecation and CHIPS partitioning change nothing about how this pattern resolves.

No document.write, no synchronous XHR, no render-blocking third party. Phase 0–2 execute in a few hundred microseconds and inject with defer. There is no path on which a slow network turns into an unresolved consent state.

Sandboxed contexts are never trusted. Pixel workers and sandboxed iframes send Origin: null. So does every data: URL and local file on the internet. Reflecting null back with Access-Control-Allow-Credentials: true — the defect recorded as finding 4.4 in the source report — allows credentialed cross-origin requests from arbitrary pages. Use an explicit allow-list, and authenticate the sandbox case with a signed request rather than with CORS trust.

Idempotence over instance-counting. The _init guard means a duplicated embed, a CMS block pasted twice, or a re-hydrating framework cannot reset a resolved state back to denied.


6. The state model

One decision object, three projections. Keep the projections mechanical; put no policy in them.

Category (CMP)Decision keyConsent Mode v2Commerce platformLedger
Strictly necessary— (always on)security_storage: granted—implicit
Analyticsanalyticsanalytics_storageanalyticsconsent_flags.analytics
Functional / preferencesfunctionalfunctionality_storage, personalization_storagepreferencesconsent_flags.functional
Targeting / advertisingmarketingad_storage, ad_user_data, ad_personalizationmarketing, sale_of_dataconsent_flags.marketing

Do not grant the ad keys by default. The source capture showed gcd=13r3q3r3r5l1 on the very first hit — ad_storage, ad_user_data, and ad_personalization all granted while analytics_storage was denied. For a US visitor with no opt-in mandate that is defensible. Ship the same default to an EEA or UK visitor and it is a pre-consent advertising-signal transmission. Default all six to denied and let a region-aware Phase 3 raise them; the cost of doing so in a non-opt-in region is one consent update call.

Third parties are inside the gate, not beside it

In the source capture a Merchant Center trust badge appeared to load unconditionally from a footer loader, pulling apis.google.com, a google.com iframe and RPC, gstatic.com, and Google Fonts. Reading the loader source settled it the other way: the badge injects platform.js only from inside a mount function guarded on marketing consent, and it loaded 250 ms after the consent bootstrap because it had been waiting for it. The gate was working.

Keep that correction in view, because the method matters more than the verdict. A HAR cannot distinguish "not gated" from "gated, and consent resolved a moment ago" — both look like a third-party request shortly after page load. Ordering is suggestive, never conclusive. Only two things settle it: the loader source, or a reject-all capture, where a correctly gated resource is simply absent. Treat a third-party request in a consented capture as a question, not a finding.

The rule it illustrates stands regardless: anything that opens a connection to a third party is inside the consent gate — badges, fonts, chat widgets, map embeds, video players, review widgets, A/B tools. Gate each on the functional category, or self-host it. The exposure is real even when the widget is trivial: Google Fonts served from fonts.googleapis.com was held an unlawful transfer of a visitor's IP address absent consent (LG München I, 3 O 17493/20, 2022). Self-hosting fonts is the cheaper of the two answers and removes the question entirely.


7. Conformance

Static assertions

These are cheap to run in CI against the loader source and catch the regressions that matter:

AssertionWhy it exists
The consent default call precedes the tag-injection call in source orderDenied-before-load. Ordering is the entire guarantee.
The stored replay uses 'update', never a second 'default'A re-default misrepresents a decision as an absence of one.
The _init guard is the first statement in the IIFEDuplicate embeds must not reset resolved state.
Every storage access sits inside try/catchPrivate mode and blocked-storage contexts throw, not return null.
Ledger writes are guarded by a signature comparisonTransitions only; re-affirmation is not an event.
No user-agent test appears anywhere in the consent pathPreserves device-agnosticism by construction.
Anonymous decisions park; server writes require a subject tokenConsent provenance is bound to a human, not a browser.

Runtime verification, from a HAR capture

The wire is the only place the pattern is actually true. Capture with Preserve log on, from a cold navigation with the Network panel already open — a capture that starts mid-session cannot see the default block, which is the single most important thing to verify.

Decode gcs on the first measurement hit. The format is G1XY, where X is ad_storage and Y is analytics_storage:

gcsad_storageanalytics_storageVerdict on a first hit
G100denieddeniedCorrect for a new visitor pre-decision.
G110granteddeniedAd-side leak. Defaults are not denied.
G101deniedgrantedAnalytics resolved, ads correctly withheld.
G111grantedgrantedCorrect only if a stored grant was replayed — see below.

Then confirm the resolution actually happened at load:

  1. First hit of a returning consented visitor carries G111 and the persistent client ID. If it carries G110/G100 with a fresh client ID and a later hit carries G111 with ga_temp_client_id attached, Phase 1 is missing. That is precisely the defect in section 1.
  2. No stats.g.doubleclick.net ping before both ad and analytics storage are granted.
  3. No third-party host — fonts, badges, widgets — appears before the resolution moment.
  4. The ledger write is present as a completed 2xx, not only as a 204 preflight. Seven OPTIONS requests returned 204 in the source capture with no matching POST recorded. A preflight proves the browser asked permission. It does not prove the write landed.

When a sanitized export hides the follow-up, the server side settles it and is the stronger evidence anyway. In the source case the consent ledger held five hash-chained rows whose ga_session_id carried the session component s1788525894 — byte-identical to the session half of the HAR's own temporary client ID 362792477.1788525894 — written 28 seconds after that session began. The write had landed all along; only the proof was missing from the capture. Join on the identifier the client sent, not on wall-clock time, and a sanitized HAR stops being the last word.

Adversarial captures

Three additional captures, each of which tends to reveal a different class of defect:


8. Adoption checklist


Appendix — parameter reference

Parameters carried on Google measurement hits, as observed in the source capture.

ParameterMeaning
gcsConsent state at hit time: G1 + ad_storage + analytics_storage (0 denied, 1 granted).
gcdFull consent-mode descriptor across all types, including whether a default or an update produced the state.
cidClient ID. A value not present in a prior session indicates a temporary, cookieless identifier.
ep.ga_temp_client_idA temporary ID from an earlier cookieless hit, sent for post-hoc stitching. Its presence is the signature of a missing Phase 1.
npaNon-personalised ads. 0 = personalisation permitted.
dmaDigital Markets Act scope. 1 = EEA consent requirements apply to this hit.
aipAnonymise IP.

This document is the portable pattern — how any template adopts consent-at-load. Two adjacent documents cover the parts deliberately left out of it, and neither is restated here:


Source report: crm-sync-consent-resolution-report.docx, Story Story AI, 2026-09-04. Reference implementation: Tier A of the CRM Sync stack loader.