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.

Normalizing GDPR and CCPA request labels onto one canonical action enum A raw intake payload enters jurisdiction resolution, which splits three ways: GDPR maps to Articles 15 to 21, CCPA/CPRA maps to sections 1798.100 to 135, and an unmatched jurisdiction fails closed to manual triage. Both regime label sets converge on a shared canonical action enum with seven members: access, erase_delete, correct, restrict, port, object, and opt_out. GDPR contributes access, correct, erase_delete, restrict, port and object; CCPA contributes access, correct, erase_delete and opt_out. Restrict, port and object have no CCPA analogue, and opt_out has no GDPR analogue. The canonical action is wrapped in a normalized envelope carrying jurisdiction and SLA, then forked to three physically isolated fulfillment paths: access drives cross-system data mapping, erase or delete drives cascading tombstones with proof of erasure, and opt-out drives a suppression signal to the consent platform and ad-tech. Raw intake payload Jurisdiction resolution GDPR label setArt. 15–21 CCPA / CPRA categories§§1798.100–135 UNKNOWN —fail closed to triage CanonicalAction enum ACCESS · ERASE_DELETE · CORRECT RESTRICT · PORT · OBJECT · OPT_OUT RESTRICT/PORT/OBJECT: GDPR · OPT_OUT: CCPA Envelope: action + jurisdiction + SLA ACCESS →cross-system data mapping ERASE_DELETE →tombstones + proof of erasure OPT_OUT →suppression to consent + ad-tech GDPR CCPA / CPRA unmatched map map

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_right with no entry in TAXONOMY_MAP (after alias normalization) raises LookupError and 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.UNKNOWN and 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 ALIASES table normalizes the known surface forms to opt_out; anything ambiguous fails closed rather than executing an irreversible deletion.
  • GDPR-only rights sent under CCPA. A CCPA-tagged request for restriction or portability has 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_MAP and ALIASES are tiny, immutable dicts — load them once at process start. Cache resolved envelopes per request_id in Redis only to make retries idempotent, never to skip re-validation.
  • Partition the broker by jurisdiction. Keying Kafka partitions on jurisdiction keeps 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_size guard 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_canonical a matrix crossing every Jurisdiction with valid labels, aliased labels, cross-regime labels (e.g. CCPA portability), and garbage; assert each valid case returns the expected (CanonicalAction, citation) and each invalid case raises LookupError.
  • Asymmetry regression. Assert that CCPA opt_out never maps to OBJECT and that GDPR objection never maps to OPT_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 UNKNOWN handling is exercised before it is needed.
  • Citation completeness. Assert every TAXONOMY_MAP value 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.