Building a GDPR/CCPA Decision Matrix in Python

Within the broader GDPR vs CCPA Request Taxonomies framework — itself part of the DSR Architecture & Intake Routing control plane — this page builds the single lookup structure that turns a resolved (framework, request_type) pair into a concrete, machine-executable directive: the legal basis to cite, the retention constraint to honor, the statutory deadline, and the fulfillment action to dispatch. Privacy engineers hit this problem the moment normalization succeeds: intake has decided which statute governs and which canonical right was asked for, but no worker yet knows whether to run GDPR Art. 17 erasure with its lawful-basis exceptions or a CCPA §1798.105 deletion with its retention exceptions — and getting that wrong is an irreversible, reportable defect. A decision matrix keyed on both dimensions makes the answer a data lookup rather than a tangle of conditionals scattered across services, and it fails closed to the most protective directive when the pair is unrecognized. This is the executable core the sibling pages assume: How to Map DSR Types to GDPR Article 15 expands the access cell into cross-system discovery, and Handling CCPA Deletion vs Opt-Out Requests keeps two of these cells on separate state machines.

The matrix collapses the two-dimensional lookup — governing framework crossed with resolved request type — into one immutable directive, with an UNKNOWN fallback that never guesses:

GDPR/CCPA decision matrix lookup from a resolved key to an executable directive A resolved Framework enum value and a resolved RequestType enum value are combined into a composite key, the tuple of framework and request type. That key is passed to a frozen decision matrix lookup. A decision gate asks whether the key is present in the matrix. If the key is present, the lookup returns a Directive carrying the legal basis citation, the retention constraint, the statutory deadline in days, and the execution action to dispatch. If the key is absent, the resolver falls through to the UNKNOWN fallback, which returns the most protective directive — GDPR erasure semantics with the shortest retention and the tightest one-month deadline — and flags the request for manual triage rather than guessing a weaker obligation. Framework (resolved) RequestType (resolved) key = (framework, request_type) DECISION_MATRIX.get(key) — frozen key present? Directive — legal basis,retention, deadline, action UNKNOWN fallback → mostprotective + manual triage yes no
A resolved framework and request type form one key; a hit returns an executable directive, a miss fails closed to the most protective directive.

Prerequisites

The matrix targets Python 3.11+ (for tuple[...] generic syntax and enum.StrEnum without back-ports) and one runtime dependency:

  • pydantic (v2) — models the Directive value object and validates any externally sourced matrix override at load time. The code uses v2 idioms only (model_config = ConfigDict(...), model_validate, field_validator); v1 patterns (class Config, @validator, .dict()) will not work.

No network, database, or KMS access is needed here — the matrix is a pure, in-process function. It sits after jurisdiction resolution and canonical-action mapping (both owned upstream by GDPR vs CCPA Request Taxonomies) and before the fulfillment workers. The distinct GDPR rights it encodes are Art. 15 (access), Art. 16 (rectification), Art. 17 (erasure), Art. 18 (restriction), Art. 20 (portability), and Art. 21 (objection); the CCPA/CPRA categories are §1798.100 (right to know/access), §1798.105 (deletion), §1798.106 (correction), and §1798.120 (opt-out of sale or sharing).

Step-by-Step Implementation

Step 1 — Enumerate the two lookup axes

Both dimensions are closed sets, so model them as enums. An enum makes an unknown value a loud failure at the boundary and lets the matrix be exhaustively checked in tests. Framework carries the resolved statute (never the raw regulator wording); RequestType carries the canonical action already normalized upstream.

from enum import StrEnum


class Framework(StrEnum):
    """The governing statute, resolved before the matrix is consulted."""
    GDPR = "gdpr"
    CCPA = "ccpa"          # CCPA as amended by the CPRA
    UNKNOWN = "unknown"    # residency could not be attested


class RequestType(StrEnum):
    """Canonical action, normalized from the regime-specific label."""
    ACCESS = "access"          # GDPR Art. 15 / CCPA §1798.100
    ERASE = "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 (no CCPA analogue)
    OBJECT = "object"          # GDPR Art. 21 (no CCPA analogue)
    OPT_OUT = "opt_out"        # CCPA §1798.120 (no GDPR analogue)

Step 2 — Model the directive as an immutable value object

Each matrix cell resolves to a Directive: the concrete instruction a fulfillment worker executes. It pins the statutory citation that must appear in the audit trail, the retention constraint governing any residual copy, the deadline in days, and the queue the request forks onto. Modeling it with Pydantic v2 and frozen=True guarantees no worker can mutate a directive after lookup.

from pydantic import BaseModel, ConfigDict, Field


class Directive(BaseModel):
    """Executable instruction returned for one (framework, request_type) cell."""
    model_config = ConfigDict(frozen=True, extra="forbid")

    legal_basis: str = Field(min_length=3)          # e.g. "GDPR Art. 17"
    action: str                                     # fulfillment queue / handler
    deadline_days: int = Field(gt=0, le=90)         # statutory response window
    retention_constraint: str                       # what may survive, and why
    reversible: bool                                # is the action undoable?

Step 3 — Freeze the matrix keyed on both axes

The matrix is a module-level dict keyed on the (Framework, RequestType) tuple. It is the single source of truth: no worker re-derives a directive from strings. Because a dict is mutable, the accessor in Step 4 never exposes it directly, and the Directive values are themselves frozen — so the table behaves as an immutable configuration artifact loaded once at process start.

DECISION_MATRIX: dict[tuple[Framework, RequestType], Directive] = {
    # --- GDPR: six discrete, independently executable rights ---
    (Framework.GDPR, RequestType.ACCESS): Directive(
        legal_basis="GDPR Art. 15", action="eu_access_export",
        deadline_days=30, retention_constraint="copy retained per Art. 5(1)(e) minimization",
        reversible=True),
    (Framework.GDPR, RequestType.ERASE): Directive(
        legal_basis="GDPR Art. 17", action="eu_erasure_cascade",
        deadline_days=30, retention_constraint="legal-hold + Art. 17(3) exceptions only",
        reversible=False),
    (Framework.GDPR, RequestType.CORRECT): Directive(
        legal_basis="GDPR Art. 16", action="eu_rectify",
        deadline_days=30, retention_constraint="prior value logged for Art. 5(2) accountability",
        reversible=True),
    (Framework.GDPR, RequestType.RESTRICT): Directive(
        legal_basis="GDPR Art. 18", action="eu_restrict_processing",
        deadline_days=30, retention_constraint="data preserved, processing frozen",
        reversible=True),
    (Framework.GDPR, RequestType.PORT): Directive(
        legal_basis="GDPR Art. 20", action="eu_portability_export",
        deadline_days=30, retention_constraint="structured machine-readable copy",
        reversible=True),
    (Framework.GDPR, RequestType.OBJECT): Directive(
        legal_basis="GDPR Art. 21", action="eu_objection_review",
        deadline_days=30, retention_constraint="pending lawful-basis balancing test",
        reversible=True),
    # --- CCPA / CPRA: four consumer categories ---
    (Framework.CCPA, RequestType.ACCESS): Directive(
        legal_basis="CCPA §1798.100", action="ca_know_export",
        deadline_days=45, retention_constraint="12-month lookback per §1798.130",
        reversible=True),
    (Framework.CCPA, RequestType.ERASE): Directive(
        legal_basis="CCPA §1798.105", action="ca_delete_cascade",
        deadline_days=45, retention_constraint="§1798.105(d) retention exceptions only",
        reversible=False),
    (Framework.CCPA, RequestType.CORRECT): Directive(
        legal_basis="CCPA §1798.106", action="ca_correct",
        deadline_days=45, retention_constraint="prior value logged for audit",
        reversible=True),
    (Framework.CCPA, RequestType.OPT_OUT): Directive(
        legal_basis="CCPA §1798.120", action="ca_suppression_signal",
        deadline_days=15, retention_constraint="data retained; sale/sharing suppressed",
        reversible=True),
}

The asymmetry is deliberate: GDPR contributes RESTRICT, PORT, and OBJECT with no CCPA analogue, and CCPA contributes OPT_OUT with no GDPR analogue. The opt-out deadline_days=15 reflects the CCPA/CPRA requirement to act on an opt-out of sale or sharing within 15 business days — a tighter window than the 45-day access/deletion clock. The California Attorney General’s CCPA regulations are the authoritative source for these timing rules.

Step 4 — Resolve with a fail-closed UNKNOWN fallback

The accessor is the only public entry point. On a hit it returns the frozen directive; on a miss — an unrecognized pair, or an UNKNOWN framework because residency could not be attested — it returns the most protective directive rather than guessing a weaker one. The most protective posture is GDPR erasure semantics: the shortest retention, an irreversible action treated with maximum caution, and the tightest one-month deadline. Every miss is also flagged for manual triage so a compliance officer resolves the true obligation before the clock is trusted.

import logging

logger = logging.getLogger("dsr.decision_matrix")

# The most protective directive: assume the strictest regime on ambiguity.
MOST_PROTECTIVE = Directive(
    legal_basis="GDPR Art. 17 (defaulted — ambiguous request)",
    action="manual_triage",
    deadline_days=30,
    retention_constraint="shortest retention; no processing until resolved",
    reversible=False,
)


def resolve(framework: Framework, request_type: RequestType) -> Directive:
    """Return the executable directive for a resolved request pair.

    Fails closed: an unmapped pair or UNKNOWN framework yields the most
    protective directive and is routed to manual triage, never guessed.
    """
    directive = DECISION_MATRIX.get((framework, request_type))
    if directive is None:
        logger.warning(
            "unmapped_directive framework=%s request_type=%s -> most_protective",
            framework, request_type,
        )
        return MOST_PROTECTIVE
    return directive

Returning the most protective directive on ambiguity implements the GDPR Art. 5(2) accountability principle in code: the pipeline can always show it never under-applied an obligation, and the triage flag preserves the original pair for a human decision.

Configuration Reference

The matrix itself is the configuration. Each row is one cell; the compliance note names the provision the directive enforces.

Key (framework, request_type) action deadline_days reversible Compliance note
(GDPR, ACCESS) eu_access_export 30 True GDPR Art. 15 subject access, one-month window (Art. 12(3)).
(GDPR, ERASE) eu_erasure_cascade 30 False GDPR Art. 17 erasure; only Art. 17(3) exceptions survive.
(GDPR, CORRECT) eu_rectify 30 True GDPR Art. 16 rectification; prior value logged.
(GDPR, RESTRICT) eu_restrict_processing 30 True GDPR Art. 18; data preserved, processing frozen.
(GDPR, PORT) eu_portability_export 30 True GDPR Art. 20 structured machine-readable copy.
(GDPR, OBJECT) eu_objection_review 30 True GDPR Art. 21; pending lawful-basis balancing test.
(CCPA, ACCESS) ca_know_export 45 True CCPA §1798.100 right to know, 12-month lookback.
(CCPA, ERASE) ca_delete_cascade 45 False CCPA §1798.105 deletion; §1798.105(d) exceptions only.
(CCPA, CORRECT) ca_correct 45 True CCPA §1798.106 correction (CPRA-added right).
(CCPA, OPT_OUT) ca_suppression_signal 15 True CCPA §1798.120 opt-out; 15-business-day action window.
unmapped / UNKNOWN manual_triage 30 False Fail closed to most protective; human resolves the obligation.

Verification

Treat the matrix as a compliance control and test every cell plus the fallback. The critical assertions are that each valid pair returns the citation the audit trail depends on, that GDPR-only and CCPA-only rights never leak across regimes, and that any unmapped pair fails closed.

import itertools
import pytest


def test_every_valid_cell_carries_a_citation():
    """No matrix entry may have an empty legal basis."""
    for (framework, rt), directive in DECISION_MATRIX.items():
        assert directive.legal_basis, f"missing citation for {framework}/{rt}"
        # GDPR cells cite an Article; CCPA cells cite a section.
        marker = "Art." if framework is Framework.GDPR else "§1798"
        assert marker in directive.legal_basis


def test_erasure_is_irreversible_in_both_regimes():
    assert resolve(Framework.GDPR, RequestType.ERASE).reversible is False
    assert resolve(Framework.CCPA, RequestType.ERASE).reversible is False


def test_ccpa_has_no_gdpr_only_rights():
    """RESTRICT, PORT, OBJECT have no CCPA analogue -> fail closed."""
    for rt in (RequestType.RESTRICT, RequestType.PORT, RequestType.OBJECT):
        assert resolve(Framework.CCPA, rt).action == "manual_triage"


def test_unknown_framework_returns_most_protective():
    d = resolve(Framework.UNKNOWN, RequestType.ERASE)
    assert d is MOST_PROTECTIVE
    assert d.deadline_days == 30  # tightest window on ambiguity


@pytest.mark.parametrize("framework,rt", list(itertools.product(Framework, RequestType)))
def test_resolve_never_raises_and_always_returns_a_directive(framework, rt):
    """Exhaustive matrix sweep: resolve is total over the enum product."""
    assert isinstance(resolve(framework, rt), Directive)

The itertools.product sweep asserts totality — resolve returns a Directive for every combination of the two enums, so an added enum member without a matrix entry surfaces immediately as a fallback rather than a runtime KeyError. Expect the structured audit log to record, per request, the resolved legal_basis, the action, whether the fallback fired, and the original pair, so a supervisory authority can trace exactly which provision each request was executed under.

Troubleshooting

Unmapped pair silently deletes data — Root cause: a worker read DECISION_MATRIX[key] directly and caught the KeyError with a permissive default. Fix: route every lookup through resolve() so a miss returns MOST_PROTECTIVE (which is manual_triage, not an execution action), never a guessed directive.

CCPA opt-out misses its window — Root cause: the opt-out directive inherited the 45-day access/deletion clock. Fix: keep (CCPA, OPT_OUT) at deadline_days=15 for the sale/sharing action window, and assert it in the cell test so a future table edit cannot regress it.

A directive was mutated downstream — Root cause: a worker reassigned a field on the returned object. Fix: keep Directive frozen (ConfigDict(frozen=True)); mutation raises ValidationError, turning a silent corruption into a loud failure.

New enum member routes nowhere — Root cause: a RequestType or Framework value was added without a matrix cell. Fix: the exhaustive itertools.product test fails closed by returning the fallback; add the cell (or deliberately confirm the fallback is correct) before shipping.

GDPR-only right accepted under CCPA — Root cause: a caller upgraded a CCPA request to a GDPR analogue. Fix: the matrix has no (CCPA, RESTRICT/PORT/OBJECT) cells, so resolve returns manual_triage; never map an opt-out to an objection or a portability request to a know request.

Frequently Asked Questions

Why key the matrix on both framework and request type instead of just the request type?

Because the same canonical action carries different obligations per statute. A GDPR erasure (Art. 17) and a CCPA deletion (§1798.105) both delete data, but they have different legal bases, retention exceptions, and deadlines. Keying on the composite tuple lets one lookup return the regime-correct directive, so no downstream worker re-derives the legal analysis from a string.

What does the matrix do when it has never seen a request pair?

The resolve function returns the most protective directive — GDPR erasure semantics with the shortest retention and the tightest one-month deadline — and routes the request to manual triage. It never guesses the closest cell, because a mis-applied erasure is irreversible. A human maps the novel pair and the matrix gains a cell so the next occurrence resolves automatically.

Why is the CCPA opt-out deadline 15 days when access and deletion are 45?

CCPA §1798.100 access and §1798.105 deletion carry the 45-day response window, but a §1798.120 opt-out of sale or sharing must be actioned within 15 business days under the CPRA regulations. Encoding both windows as distinct deadline_days values on their cells keeps the opt-out suppression signal from inheriting the longer clock and missing its statutory window.

Which regulations anchor the directives in the matrix?

The GDPR cells cite Articles 15, 16, 17, 18, 20, and 21, with the one-month window from Art. 12(3). The CCPA/CPRA cells cite §1798.100 for the right to know, §1798.105 for deletion, §1798.106 for correction, and §1798.120 for opt-out. The fail-closed default to the most protective directive implements the GDPR Art. 5(2) accountability principle in code.