Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/trackingv0.7.1· Specification

spec-consent — Categories, Regions, Records, Effective Consent

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.

1. Categories and decisions

Consent is per category, mirroring Google Consent Mode v2's split:

  • analytics — gates measurement (maps to analytics_storage).
  • ads — gates advertising storage and data sharing (maps to ad_storage, ad_user_data, ad_personalization).

A consent decision is a map with exactly these keys, each an explicit boolean:

{ "ads": false, "analytics": true }
  • createConsentDecision(granted) — a decision granting exactly the given categories and denying every other one.
  • Shape guard: a value read from untrusted storage is a valid decision only if it is a plain object (not an array, not null) and every category is an explicit boolean. A single granted/denied flag, a string, or a missing category MUST be rejected — GDPR consent must be granular per purpose.
  • Normalization: re-derive a clean decision from a tamperable record — extra keys dropped, every category an explicit boolean. Client and server MUST share this one normalization rule.

2. Regions and modes

Region values: eu, vn, us, other.

Region resolution from an ISO 3166-1 alpha-2 country code (case-insensitive):

Input Region
AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE (EU-27) eu
GB IS LI NO (UK GDPR + EEA/EFTA, GDPR-equivalent) eu
VN vn
US us
any other non-empty code other

A missing country code is handled one level up (spec-server-lane §2): unknown is not known-elsewhere, so it MUST fail closed to the strictest default — never fall through to other's opt-out.

Mode resolution: GDPR (EU) and Vietnam's PDPL (Luật 91/2025/QH15) require explicit opt-in; CCPA/CPRA (US) defaults to opt-out. There is no single global default satisfying both, so:

Region Mode
eu, vn opt-in
us, other opt-out

resolveDefaultConsent(mode, requestedCategories, hasGpcSignal):

  1. opt-in mode → all categories denied.
  2. opt-out mode → grant exactly requestedCategories (the purposes the app's prompt asks about), deny the rest.
  3. If a GPC signal is present, force ads to denied. GPC is a "do not sell or share" opt-out (CCPA/CPRA): it MUST NOT withdraw first-party analytics measurement, and it only bites under opt-out (opt-in is already all-denied).

One configuration object, defined once and passed to every surface (UI, tracker gate, tag bootstrap), so cross-surface constants hold by construction:

  • storageKey — the persistence key holding the visitor's consent record.
  • policyVersion — an opaque string; bumping it re-prompts every visitor (a stored decision under any other version is ignored).
  • requestedCategories — the categories the app's prompt asks about; grant-all and opt-out defaults grant exactly these.

The persisted record:

{ "decision": { "ads": false, "analytics": true }, "policyVersion": "2026-07", "timestamp": 1752796800000 }
  • Persisted as plain JSON with exactly this shape — a stable contract, so a pre-hydration bootstrap script (spec-destinations §4) can read the decision synchronously before any tag fires.
  • Record guard: valid only if decision passes the shape guard (§1), policyVersion is a string, and timestamp is a number.

Storage contract (implementation supplies the backend):

  • load() → record | none, save(record), clear(), and subscribe(listener) → unsubscribe.
  • subscribe MUST notify on any change to the stored record, including writes from other tabs/processes where the platform allows it.
  • Degradation: corrupt state, a blocked store (private mode, quota), or a missing platform store MUST degrade to an in-memory record for the session — the visitor's decision still applies instead of re-prompting in a loop. A malformed persisted value reads as "no record", never as a decision.
  • After a successful persistent write, any in-memory fallback copy MUST be dropped — the persistent store is authoritative, and a lingering memory copy would shadow an external clear (e.g. the visitor wiping site data).
  • Implementations SHOULD cache parse+validate per raw value so per-event gate checks cost a memory read, not a parse.

6. Reading the stored decision

readStoredDecision(storage, policyVersion) → decision | none:

  1. load() the record; none → none.
  2. record.policyVersion differs from the current policyVersion → none (superseded policy re-prompts).
  3. record.decision fails the shape guard → none (garbage re-prompts, never silently denies or grants).
  4. Otherwise return the normalized decision (§1).

The consent a tracker and every tag MUST honor right now:

effectiveConsent = readStoredDecision(storage, policyVersion)
                   ?? resolveDefaultConsent(mode, requestedCategories, hasGpcSignal)
  • isAnalyticsAllowed = effectiveConsent.analytics — this single boolean gates the tracking pipeline.
  • The GPC signal MUST be re-read at each evaluation (it can change mid-session).
  • Prompt rule: a consent prompt is needed iff mode == "opt-in" and no stored decision exists. Opt-out regions never block on a prompt.
  • Saving a decision writes { decision, policyVersion (current), timestamp (now) } and notifies subscribers; deny-all saves all-false, grant-all saves exactly requestedCategories granted.

The server lane resolves a per-visitor value handed to the client so it never re-guesses the mode:

{ "defaultConsent": { "ads": false, "analytics": false }, "mode": "opt-in", "region": "other" }
  • Strictest initial consent — the constant { defaultConsent: all denied, mode: "opt-in", region: "other" }: the one default safe to show any visitor anywhere. Shared/CDN-cached HTML MUST bake this and nothing region-specific.
  • Guard for values read from untrusted storage (a session cache, a cookie): mode and region must be valid enum members, defaultConsent must pass the decision shape guard, and the pairing must satisfy mode == resolveConsentMode(region) — with exactly one exception, the strictest constant's own pairing (opt-in + other), which is stricter than the region's usual mode, never looser.

9. Withdrawal side effects

When a decision is saved with analytics == false (deny or withdraw), the implementation MUST clear first-party tracking state: the anonymous id (spec-identity §5) and any destination identifier cookies (e.g. Google's _ga*, spec-destinations §4). Grant paths are a no-op.

Conformance vectors

V1 — region table. "de"eu; "GB"eu; "vn"vn; "US"us; "BR"other; "JP"other.

V2 — mode table. euopt-in; vnopt-in; usopt-out; otheropt-out.

V3 — defaults. (opt-in, requested ["ads","analytics"], gpc any) → {ads:false, analytics:false}. (opt-out, requested ["analytics"], gpc false) → {ads:false, analytics:true}. (opt-out, requested ["ads","analytics"], gpc true) → {ads:false, analytics:true}.

V4 — decision guard. {"ads":true,"analytics":false} valid; {"ads":true} invalid; {"ads":"yes","analytics":true} invalid; "granted" invalid; ["ads"] invalid; null invalid.

V5 — normalization. {"ads":true,"analytics":true,"extra":"x"}{"ads":true,"analytics":true} (extra key dropped).

V6 — policy supersession. Stored record with policyVersion:"v1", current version "v2"readStoredDecision returns none; effective consent falls back to the regional default; prompt needed under opt-in.

V7 — effective consent precedence. Opt-out region, requested ["analytics"], stored decision {ads:false, analytics:false} → effective {ads:false, analytics:false} (stored wins over the granting default).

V8 — initial-consent guard. {defaultConsent:{ads:false,analytics:false}, mode:"opt-in", region:"other"} valid (strictest exception). {…, mode:"opt-out", region:"other"} valid (usual pairing). {…, mode:"opt-out", region:"eu"} invalid (looser than the region's mode). {…, mode:"opt-in", region:"us"} invalid (only the strictest constant's pairing may deviate).

License

Released under the MIT License.