GDPR vs CCPA Request Taxonomies: Normalizing Divergent Rights at Intake
Within the broader DSR Architecture & Intake Routing framework, the intake layer has to reconcile two regulatory regimes that describe the same human request with incompatible vocabularies. GDPR models a discrete set of independently executable rights (access, rectification, erasure, restriction, portability, objection under Articles 15–21); the CCPA as amended by the CPRA collapses those into a smaller set of consumer-facing categories (know/access, delete, correct, and opt out of sale or sharing under Cal. Civ. Code §§1798.100–1798.135). If a pipeline stores the raw regime-specific label and lets each downstream worker re-interpret it, you get silent taxonomy drift: a CCPA “delete” gets executed with GDPR erasure semantics, an opt-out gets treated as a deletion, and the response window is computed against the wrong statute. This page addresses that specific gap — how to map both taxonomies onto one canonical internal request type at the edge, so every downstream stage sees a normalized, jurisdiction-tagged envelope rather than a regulator’s wording.
The taxonomy problem is fundamentally a normalization-then-routing problem. The intake gate must (1) validate the raw payload, (2) resolve jurisdiction and assign the correct statutory clock, (3) map the regime-specific label to a canonical action, and (4) dispatch onto a fulfillment path that keeps GDPR extraction semantics cryptographically isolated from CCPA deletion and opt-out semantics. The phases below implement exactly that spine, and they hand off to the sibling Jurisdiction Routing Logic and 30-Day vs 45-Day SLA Mapping stages for the routing and deadline mechanics that this page depends on.
Phase 1: Schema Validation and Label Capture at the Edge
Intake validation rejects malformed or ambiguous payloads before they can consume the response window or corrupt the audit trail. The canonical response window is fixed by statute — GDPR Art. 12(3) sets one month, CCPA §1798.130(a)(2) sets 45 days — so a payload that cannot be classified must never be silently accepted. Use Pydantic v2 at the edge for runtime type checking, enumerated jurisdiction and label capture, and structural rejection. Anti-replay and cryptographic-nonce concerns at the form boundary belong to the Secure Intake Form Design stage; this phase assumes a signed, deduplicated payload and focuses on taxonomy capture.
The model captures the regime-specific label verbatim in raw_right alongside the resolved jurisdiction; canonical mapping happens in Phase 3, never at parse time, so the audit trail preserves exactly what the requester (or their regulator’s template) asked for.
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator
class Jurisdiction(str, Enum):
GDPR = "GDPR"
CCPA = "CCPA" # CCPA as amended by CPRA
UNKNOWN = "UNKNOWN"
class DSRIntake(BaseModel):
# extra="forbid" makes schema drift a loud failure, not a silent leak.
model_config = ConfigDict(extra="forbid", frozen=True)
request_id: str = Field(..., pattern=r"^REQ-\d{8}$")
jurisdiction: Jurisdiction
raw_right: str = Field(..., min_length=2, max_length=64)
subject_email: str | None = Field(
default=None,
pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$",
)
received_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
@field_validator("received_at")
@classmethod
def must_be_aware(cls, v: datetime) -> datetime:
# datetime.utcnow() is deprecated in 3.12+; require tz-aware input so
# the SLA clock in 30-Day vs 45-Day SLA Mapping never drifts.
if v.tzinfo is None:
raise ValueError("received_at must be timezone-aware (UTC)")
return v
An invalid payload raises pydantic.ValidationError, which the edge proxy catches and logs with structured audit metadata (request id, rejection reason, hashed source) before returning a 400. Validated models are serialized to Avro or Protobuf for compact broker transmission. Capturing raw_right as a free-form-but-bounded string rather than an enum is deliberate: regulator and vendor request templates use dozens of surface spellings (“right to be forgotten”, “Article 17”, “erase my data”, “do not sell my personal information”), and forcing an enum at parse time would reject legitimate requests that the mapping table in Phase 3 can resolve.
Phase 2: Jurisdiction Resolution and SLA Assignment
Jurisdiction resolution assigns the statutory clock and must complete before any canonical mapping, because the same word means different things under each regime. GDPR Art. 12(3) mandates a one-month baseline, extensible by up to two further months for complex or numerous requests with notice to the data subject. CCPA §1798.130(a)(2) mandates 45 days, extensible once by a further 45 days with notice. The Jurisdiction Routing Logic stage owns the resolution signals (residency attestation, IP geolocation as a weak hint, explicit regime declaration); the 30-Day vs 45-Day SLA Mapping stage owns the countdown state machine. This phase only records which clock applies so the envelope carries an unambiguous deadline downstream.
The clock is injected as a TTL on the routing envelope. Message brokers (RabbitMQ TTL, or Kafka with a scheduled/tombstoning consumer) evaluate the TTL and route near-expiry payloads to a high-priority queue that compliance dashboards poll. For the authoritative statutory response windows the pipeline enforces, see the ICO Right of Access guidance and the California civil code text of the CCPA.
At a glance, the two regimes diverge across scope, timing, and fulfillment mechanics — the differences the mapping table in Phase 3 has to absorb:
| Dimension | GDPR (Art. 15–21) | CCPA / CPRA (§§1798.100–135) |
|---|---|---|
| Rights model | Discrete, independently executable rights | Consolidated consumer categories |
| Standard response window | 1 month (Art. 12(3)) | 45 days (§1798.130(a)(2)) |
| Extension | Up to 2 further months, with notice | One further 45 days, with notice |
| Primary scope | EU/EEA data subjects (extraterritorial, Art. 3) | California consumers |
| Deletion basis | Erasure under Art. 17, with lawful-basis exceptions | Delete under §1798.105, with retention exceptions |
| Distinctive routing | Isolated execution context per right | Delete kept separate from opt-out of sale/sharing |
Phase 3: Canonical Action Mapping
Canonical mapping is the heart of this stage: it translates each regime’s raw_right into one shared CanonicalAction that every downstream worker understands, while preserving the original label and its statutory citation for the audit trail. The mapping table is the single source of truth — no worker should re-parse raw_right. Modeling this as an explicit lookup (rather than string heuristics scattered across services) means a new regulator wording is a one-line table change, and an unmatched label deterministically falls through to UNKNOWN rather than being guessed.
class CanonicalAction(str, Enum):
ACCESS = "access" # GDPR Art. 15 / CCPA right to know
ERASE_DELETE = "erase" # GDPR Art. 17 / CCPA 1798.105
CORRECT = "correct" # GDPR Art. 16 / CCPA 1798.106
RESTRICT = "restrict" # GDPR Art. 18 (no CCPA analogue)
PORT = "port" # GDPR Art. 20 (portability)
OBJECT = "object" # GDPR Art. 21 (no CCPA analogue)
OPT_OUT = "opt_out" # CCPA 1798.120 (no GDPR analogue)
# (jurisdiction, normalized raw label) -> (canonical action, citation)
TAXONOMY_MAP: dict[tuple[Jurisdiction, str], tuple[CanonicalAction, str]] = {
(Jurisdiction.GDPR, "access"): (CanonicalAction.ACCESS, "GDPR Art. 15"),
(Jurisdiction.GDPR, "rectification"): (CanonicalAction.CORRECT, "GDPR Art. 16"),
(Jurisdiction.GDPR, "erasure"): (CanonicalAction.ERASE_DELETE, "GDPR Art. 17"),
(Jurisdiction.GDPR, "restriction"): (CanonicalAction.RESTRICT, "GDPR Art. 18"),
(Jurisdiction.GDPR, "portability"): (CanonicalAction.PORT, "GDPR Art. 20"),
(Jurisdiction.GDPR, "objection"): (CanonicalAction.OBJECT, "GDPR Art. 21"),
(Jurisdiction.CCPA, "know"): (CanonicalAction.ACCESS, "CCPA 1798.110"),
(Jurisdiction.CCPA, "access"): (CanonicalAction.ACCESS, "CCPA 1798.110"),
(Jurisdiction.CCPA, "delete"): (CanonicalAction.ERASE_DELETE, "CCPA 1798.105"),
(Jurisdiction.CCPA, "correct"): (CanonicalAction.CORRECT, "CCPA 1798.106"),
(Jurisdiction.CCPA, "opt_out"): (CanonicalAction.OPT_OUT, "CCPA 1798.120"),
}
# Common surface spellings normalized to the map's keys before lookup.
ALIASES: dict[str, str] = {
"right to be forgotten": "erasure",
"article 17": "erasure",
"erase my data": "erasure",
"do not sell my personal information": "opt_out",
"do not sell or share": "opt_out",
"right to know": "know",
"subject access request": "access",
"sar": "access",
}
def to_canonical(intake: "DSRIntake") -> tuple[CanonicalAction, str]:
"""Resolve a raw regime label to a canonical action + statutory citation.
Raises KeyError-derived LookupError so the caller can route UNKNOWN to
manual triage instead of guessing (fail closed).
"""
key = intake.raw_right.strip().lower()
key = ALIASES.get(key, key)
try:
return TAXONOMY_MAP[(intake.jurisdiction, key)]
except KeyError as exc:
raise LookupError(
f"Unmapped right {intake.raw_right!r} for {intake.jurisdiction}"
) from exc
The mapping deliberately has no CCPA analogue for RESTRICT, PORT, or OBJECT, and no GDPR analogue for OPT_OUT. This asymmetry is the whole reason a naive “just lowercase the label” approach fails: a CCPA opt-out is not a GDPR objection, and treating them as the same canonical action would route an ad-tech suppression request into a GDPR objection workflow that expects a lawful-basis balancing test. Detailed access-side mapping — how CanonicalAction.ACCESS under GDPR expands into cross-system discovery — is covered in How to map DSR types to GDPR Article 15.
Phase 4: Dispatch onto Isolated Fulfillment Paths
Dispatch attaches routing metadata to an immutable envelope and hands it to the correct fulfillment path without ever mutating the original intake. Priority is derived from the canonical action and the statutory clock — an erasure/deletion under the tighter GDPR one-month window outranks a 45-day CCPA opt-out — and escalation hooks fire when a payload nears its deadline.
import heapq
from dataclasses import dataclass, field
@dataclass(order=True)
class DispatchEnvelope:
priority: int
action: CanonicalAction = field(compare=False)
citation: str = field(compare=False)
payload: DSRIntake = field(compare=False)
escalation_hook: str = field(compare=False)
class DSRDispatcher:
def __init__(self, max_queue_size: int = 10_000) -> None:
self.queue: list[DispatchEnvelope] = []
self.max_size = max_queue_size
def route(self, intake: DSRIntake) -> DispatchEnvelope:
action, citation = to_canonical(intake)
# Lower number = higher priority (heapq is a min-heap).
priority = 1 if action is CanonicalAction.ERASE_DELETE else 2
if intake.jurisdiction is Jurisdiction.GDPR:
priority -= 1 # tighter one-month statutory window
envelope = DispatchEnvelope(
priority=priority,
action=action,
citation=citation,
payload=intake,
escalation_hook=f"compliance-alert-{intake.jurisdiction.value}",
)
if len(self.queue) < self.max_size:
heapq.heappush(self.queue, envelope)
else:
self._trigger_fallback(intake)
return envelope
def _trigger_fallback(self, intake: DSRIntake) -> None:
# Route to manual-review queue and page compliance officers.
...
Each canonical action forks to a physically isolated worker pool so that GDPR extraction logic can never leak into a CCPA deletion or opt-out workflow. Access requests (GDPR Art. 15, CCPA right to know) drive cross-system data mapping into a structured, machine-readable copy. Deletion/erasure requests (GDPR Art. 17, CCPA §1798.105) cascade tombstone records to downstream processors and collect cryptographic proof of erasure. Opt-out requests (CCPA §1798.120) emit a suppression signal to the consent-management platform and ad-tech (real-time bidding) suppression endpoints — and nothing else. Conflating deletion with opt-out is a material compliance error under the CPRA; the state-machine implementations and processor-notification templates that keep them apart live in Handling CCPA deletion vs opt-out requests.
Edge Cases and Conflict Resolution
The taxonomy mapping is where ambiguous, conflicting, or novel requests surface. Resolve each deterministically rather than guessing:
- Unmapped label under a known regime. A
raw_rightwith no entry inTAXONOMY_MAP(after alias normalization) raisesLookupErrorand routes to manual triage. Never default an unknown label to the “closest” action — a mis-mapped erasure is unrecoverable. - UNKNOWN jurisdiction. If Phase 2 cannot attest residency, tag
Jurisdiction.UNKNOWNand fail closed to triage. Do not assume the shorter GDPR clock (it may over-restrict) or the longer CCPA clock (it may breach a genuine GDPR request); a human resolves jurisdiction before the clock is trusted. - Multi-jurisdiction overlap. A subject who is both an EU resident and a California consumer can hold rights under both regimes for the same identifier. Fan out into two independent envelopes, each with its own canonical action, citation, and clock, and reconcile only at the audit layer — never collapse them into one “best” action.
- Opt-out arriving as “delete”. Ad-tech vendors frequently forward “do not sell” using the word delete. The
ALIASEStable normalizes the known surface forms toopt_out; anything ambiguous fails closed rather than executing an irreversible deletion. - GDPR-only rights sent under CCPA. A CCPA-tagged request for
restrictionorportabilityhas no statutory analogue and must not be silently upgraded to a GDPR right. It routes to triage with the reason recorded, so a compliance officer decides whether a policy-level (non-statutory) accommodation applies.
Performance and Scale Considerations
Classification sits on the hot path of every request, so the mapping must be cheap and the dispatch must isolate failure domains:
- Cache the mapping, not the decision.
TAXONOMY_MAPandALIASESare tiny, immutable dicts — load them once at process start. Cache resolved envelopes perrequest_idin Redis only to make retries idempotent, never to skip re-validation. - Partition the broker by jurisdiction. Keying Kafka partitions on
jurisdictionkeeps GDPR and CCPA traffic on separate consumer groups, so a backlog in one regime cannot starve the other’s statutory clock. - Isolate consumer groups per fulfillment path. Access, erase/delete, and opt-out workers scale independently; an opt-out spike (common after a marketing campaign) must not delay time-critical erasures.
- Bound the priority queue. The
max_queue_sizeguard sheds load to the manual-review path rather than growing an unbounded heap; size it from measured worker throughput, not optimism. - Throughput target. Classification and dispatch should complete in single-digit milliseconds; anything approaching the statutory window should already have paged an operator via the escalation hook.
Testing and Compliance Verification
Treat the taxonomy map as a compliance control and prove it before it touches production requests:
- Payload matrix. Feed
to_canonicala matrix crossing everyJurisdictionwith valid labels, aliased labels, cross-regime labels (e.g. CCPAportability), and garbage; assert each valid case returns the expected(CanonicalAction, citation)and each invalid case raisesLookupError. - Asymmetry regression. Assert that CCPA
opt_outnever maps toOBJECTand that GDPRobjectionnever maps toOPT_OUT, guarding against a future table edit that collapses the two. - Held-out regions. Include fixtures for jurisdictions your live traffic does not yet cover so
UNKNOWNhandling is exercised before it is needed. - Citation completeness. Assert every
TAXONOMY_MAPvalue carries a non-empty statutory citation so the audit artifact is always attributable to a specific article or section.
import pytest
def test_ccpa_opt_out_is_not_gdpr_objection():
intake = DSRIntake(
request_id="REQ-00000001",
jurisdiction=Jurisdiction.CCPA,
raw_right="do not sell my personal information",
)
action, citation = to_canonical(intake)
assert action is CanonicalAction.OPT_OUT
assert citation == "CCPA 1798.120"
def test_unmapped_label_fails_closed():
intake = DSRIntake(
request_id="REQ-00000002",
jurisdiction=Jurisdiction.GDPR,
raw_right="please delete my llama",
)
with pytest.raises(LookupError):
to_canonical(intake)
Frequently Asked Questions
Why normalize to a canonical action instead of routing on the raw regime label?
Because the same word carries different statutory semantics per regime, and downstream workers should never re-interpret a regulator’s wording. Mapping to a shared CanonicalAction at intake means the access worker, the erasure worker, and the opt-out worker each see one unambiguous instruction, while the original raw_right and its citation stay in the audit trail for accountability under GDPR Art. 5(2).
Is a CCPA deletion the same as a GDPR erasure?
No. GDPR erasure (Art. 17) and CCPA deletion (§1798.105) both map to the canonical ERASE_DELETE action, but they carry different lawful-basis and retention exceptions, so the fulfillment path applies regime-specific exception checks after the canonical fork. The mapping unifies the routing decision, not the legal analysis.
How should the pipeline handle a subject covered by both GDPR and CCPA?
Fan the request out into two independent envelopes — one per regime — each with its own canonical action, statutory citation, and response clock. Reconcile them only at the audit layer. Collapsing them into a single “best” action risks applying the wrong exceptions or the wrong deadline to one of the two obligations.
What happens to a request label the mapping table has never seen?
to_canonical raises LookupError and the dispatcher routes it to manual triage (fail closed). Guessing the “closest” action is unacceptable when the candidate is an irreversible erasure; a human maps the novel wording, and the alias is added to the table so the next occurrence resolves automatically.
Which regulations anchor these mappings?
The GDPR rights map to Articles 15–21 and the one-month window to Art. 12(3); the CCPA/CPRA categories map to Cal. Civ. Code §§1798.100–1798.135 and the 45-day window to §1798.130(a)(2). The audit-trail requirement follows GDPR Art. 5(2) accountability.
Related
- Jurisdiction Routing Logic — how the pipeline resolves which regime (and therefore which clock) applies to a request.
- 30-Day vs 45-Day SLA Mapping — statutory deadline translation and the countdown state machine for GDPR and CCPA/CPRA.
- Secure Intake Form Design — signed, deduplicated payloads and anti-replay protections upstream of this taxonomy gate.
- How to map DSR types to GDPR Article 15 — expanding a canonical access action into cross-system discovery and redaction.
- Handling CCPA deletion vs opt-out requests — keeping deletion and opt-out on separate state machines with distinct processor notifications.
- Up to DSR Architecture & Intake Routing — the intake and routing control plane this taxonomy gate feeds.