Choosing Masking vs Tokenization vs Deletion

A DSR redaction stage fails a regulator not by doing nothing, but by doing the plausible wrong thing — tokenizing a value in response to an erasure request because it kept referential integrity, or exporting an access response that still names a third party. The engineers who hit this are building the transform step of a DSR pipeline: detection has already located and classified the personal data, and now a single function must map each value to exactly one technique that is legally correct for the request in hand. Within the broader Redaction Strategies for DSR Fulfillment approach — itself part of the PII Extraction & Redaction Pipelines framework — this page is the concrete, runnable decision function: given a request type and a data role, return a technique, apply it, and emit a receipt. The confidence gating that decides which values reach this function automatically versus via human review is covered in the sibling Confidence Scoring & Thresholds stage.

The core rule is small and unforgiving: an erasure under GDPR Art. 17 must be irreversible, so it can never resolve to tokenization, which by the Art. 4(5) definition of pseudonymisation is reversible with additional information the controller holds; while an access export under Art. 15(4) must keep the subject’s own data and remove third parties’. The decision function encodes exactly that.

Decision function mapping request type and data role to one redaction technique A RedactionDecision carrying a request type and data role enters the first branch: is this an erasure request? If yes, a second branch asks whether the value is under a legal hold. A held value is routed to nullify, which satisfies Article 17 for retained data; an unheld value is routed to hard delete, the only unambiguous Article 17 erasure. If the request is not an erasure — an access or portability export — the function branches on data role: a linkage key routes to tokenize through a vault under the Article 4(5) pseudonymisation definition, while third-party data routes to irreversible masking and the subject's own data routes to format-preserving masking, both satisfying the Article 15(4) rights of others. Each of the four leaves is a distinct transform implementation. RedactionDecisionrequest_type × data_role erasure? legal hold? branch on data_roleaccess / export path nullify()Art. 17 · held hard_delete()Art. 17 tokenize()Art. 4(5) vault mask() / FPMArt. 15(4) yes no held delete linkage third-party / own
One decision function routes each classified value to exactly one of four transforms; an erasure request can never reach the reversible tokenize leaf.

Prerequisites

The implementation targets Python 3.10+ (for match statements and X | Y union syntax) and depends on:

  • pydantic (v2) — runtime validation of the decision inputs and outputs. The code uses v2 idioms (model_config = ConfigDict(...), field_validator, model_validate); v1 patterns (class Config, @validator, .dict()) will not work.
  • hashlib / hmac (stdlib) — one-way commitments and irreversible masking with no retained mapping.
  • A tokenization vault client — any service that stores an original value under a surrogate and returns the surrogate. The examples treat it as an injected object with put(namespace, value) -> str; adapt for your provider. Keys are managed per NIST SP 800-57.

This page assumes each value arrives already classified by request type and data role — the upstream reconciliation that assigns those tags across databases and documents is the job of Structured vs Unstructured Data Sync.

Step-by-step implementation

Step 1 — Model the decision inputs and the technique enum

Both axes are enums so the decision function can match on them exhaustively, and the technique is a closed set so no caller can invent an unlisted transform.

from enum import Enum


class RequestType(str, Enum):
    ACCESS = "access"
    PORTABILITY = "portability"
    ERASURE = "erasure"


class DataRole(str, Enum):
    SUBJECT_OWN = "subject_own"    # the requester's own personal data
    THIRD_PARTY = "third_party"    # another identifiable person's data
    LINKAGE_KEY = "linkage_key"    # cross-system join key, not disclosed
    RETAINED_UNDER_HOLD = "hold"   # erasure owed but retention required


class Technique(str, Enum):
    HARD_DELETE = "hard_delete"
    NULLIFY = "nullify"
    TOKENIZE = "tokenize"
    MASK = "mask"                  # irreversible fixed-token / hash
    FORMAT_PRESERVING = "fpm"      # irreversible, structure-preserving

Step 2 — Implement the four transforms

Each transform returns a redacted value; the reversible one also returns a vault surrogate. Crucially, hard_delete and mask retain no mapping, so they are irreversible by construction.

import hashlib


REDACTION_TOKEN = "[REDACTED]"


def irreversible_mask(value: str) -> str:
    """Replace a value with a fixed token. No mapping is retained."""
    return REDACTION_TOKEN


def hashed_mask(value: str, *, salt: bytes) -> str:
    """One-way SHA-256 mask; the digest cannot recover the input."""
    return hashlib.sha256(salt + value.encode()).hexdigest()


def format_preserving_mask(value: str) -> str:
    """Keep length and the last four characters; irreversible otherwise."""
    if len(value) <= 4:
        return "*" * len(value)
    return "*" * (len(value) - 4) + value[-4:]


def tokenize(value: str, *, namespace: str, vault) -> str:
    """Store the original in a vault and return a reversible surrogate.

    Reversal is possible only for the vault holder — this is Art. 4(5)
    pseudonymisation, NOT erasure.
    """
    return vault.put(namespace, value)


def hard_delete(value: str) -> None:
    """Destroy the value with no recovery path. Returns nothing."""
    return None

Step 3 — Model the decision output with Pydantic v2

RedactionDecision binds a technique to the reason it was chosen and enforces the one invariant that matters: an erasure request may never carry a reversible technique. The field_validator makes that a hard failure at construction time rather than a silent leak.

from pydantic import BaseModel, ConfigDict, model_validator

REVERSIBLE = {Technique.TOKENIZE}


class RedactionDecision(BaseModel):
    """The chosen technique for one value, with its legal justification."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    locator: str
    request_type: RequestType
    data_role: DataRole
    technique: Technique
    legal_basis: str  # e.g. "GDPR Art. 17"

    @model_validator(mode="after")
    def _erasure_must_be_irreversible(self) -> "RedactionDecision":
        if self.request_type is RequestType.ERASURE and self.technique in REVERSIBLE:
            raise ValueError(
                f"erasure request {self.locator} resolved to reversible "
                f"technique {self.technique.value}; Art. 17 requires irreversibility"
            )
        return self

Step 4 — The decision function

select_technique maps (request_type, data_role) to exactly one technique. It reads top-down: erasure first (it dominates every role), then the access/export role branch.

def select_technique(request_type: RequestType, data_role: DataRole) -> Technique:
    """Return the single legally-correct technique for a classified value."""
    # Erasure dominates role: Art. 17 requires irreversible removal.
    if request_type is RequestType.ERASURE:
        if data_role is DataRole.RETAINED_UNDER_HOLD:
            return Technique.NULLIFY      # cannot delete; make unusable, log basis
        return Technique.HARD_DELETE

    # Access / portability export: protect the rights of others, Art. 15(4).
    match data_role:
        case DataRole.THIRD_PARTY:
            return Technique.MASK          # suppress another person's data
        case DataRole.LINKAGE_KEY:
            return Technique.TOKENIZE      # reversible pseudonymisation, Art. 4(5)
        case DataRole.SUBJECT_OWN:
            return Technique.FORMAT_PRESERVING  # readable, subject is entitled
        case _:
            return Technique.MASK          # fail safe: over-redact, never leak


_BASIS = {
    Technique.HARD_DELETE: "GDPR Art. 17",
    Technique.NULLIFY: "GDPR Art. 17 (retention-limited)",
    Technique.TOKENIZE: "GDPR Art. 4(5)",
    Technique.MASK: "GDPR Art. 15(4)",
    Technique.FORMAT_PRESERVING: "GDPR Art. 15(4)",
}


def decide(locator: str, request_type: RequestType, data_role: DataRole) -> RedactionDecision:
    """Build a validated decision; raises if the invariant is violated."""
    technique = select_technique(request_type, data_role)
    return RedactionDecision(
        locator=locator,
        request_type=request_type,
        data_role=data_role,
        technique=technique,
        legal_basis=_BASIS[technique],
    )

The fail-safe case _ defaults to irreversible masking: on an ambiguous role the pipeline over-redacts rather than risk disclosing a third party, because under-redaction on an access export is a reportable breach while over-redaction is a recoverable service issue.

Configuration reference

Parameter Type Default Compliance note
redaction_token str "[REDACTED]" Fixed replacement for irreversible masking; retains no mapping, so it is Art. 17-safe.
hash_salt bytes per-tenant secret Salts the one-way mask so digests are not reversible via rainbow tables.
fpm_visible_tail int 4 Characters left visible in format-preserving masking; keep low to minimize residual identifiability.
token_namespace str subject-scoped Vault namespace; the same value yields the same surrogate for consistent linkage.
hold_action Technique NULLIFY Technique when erasure meets a legal hold; never HARD_DELETE until the hold lifts.
reversible_techniques set[Technique] {TOKENIZE} Set the erasure invalidator checks; erasure decisions must exclude every member.
default_ambiguous_role Technique MASK Fail-safe for unclassifiable values; over-redacts to satisfy Art. 15(4).

Verification

Two assertions are non-negotiable: an erasure request can never resolve to a reversible technique, and an access export masks third-party data while preserving the subject’s own. The RedactionDecision validator also guarantees the invariant is enforced even if a future edit to select_technique regresses.

import pytest
from pydantic import ValidationError


def test_erasure_is_never_reversible():
    for role in DataRole:
        decision = decide(f"db.users#1:{role.value}", RequestType.ERASURE, role)
        assert decision.technique not in REVERSIBLE
    # A held value nullifies rather than deletes, but is still irreversible.
    held = decide("db.users#1", RequestType.ERASURE, DataRole.RETAINED_UNDER_HOLD)
    assert held.technique is Technique.NULLIFY


def test_access_masks_third_party_keeps_subject():
    third = decide("ticket#9.body", RequestType.ACCESS, DataRole.THIRD_PARTY)
    own = decide("ticket#9.body", RequestType.ACCESS, DataRole.SUBJECT_OWN)
    assert third.technique is Technique.MASK
    assert own.technique is Technique.FORMAT_PRESERVING


def test_model_rejects_illegal_combination():
    # Force the invariant: erasure + tokenize must fail construction.
    with pytest.raises(ValidationError):
        RedactionDecision(
            locator="db.users#1", request_type=RequestType.ERASURE,
            data_role=DataRole.LINKAGE_KEY, technique=Technique.TOKENIZE,
            legal_basis="GDPR Art. 4(5)",
        )

Round-tripping through RedactionDecision.model_validate(...) on a persisted decision confirms that a stored erasure record can never deserialize into a reversible technique — the validator runs on load as well as on construction, so a tampered ledger row is caught on read.

Troubleshooting

Erasure silently tokenized to preserve foreign keys — Root cause: an engineer wired the delete path to the vault to avoid breaking referential integrity. Fix: the RedactionDecision validator rejects (ERASURE, TOKENIZE) at construction, so surface the failure and re-model the foreign key to tolerate a tombstone rather than reversing the erasure. Reversibility defeats Art. 17.

Third-party data leaks into an access export — Root cause: values classified by field type instead of data role, so a third party’s name in a shared column was treated as the subject’s. Fix: ensure each value carries a DataRole, and confirm select_technique routes THIRD_PARTY to MASK; the fail-safe case _ should also mask unclassified values.

Format-preserving mask still identifies the subject — Root cause: fpm_visible_tail too high, or the field is short enough that four visible characters are quasi-identifying. Fix: lower fpm_visible_tail, and for short high-cardinality fields prefer full masking over format preservation.

Tokens differ for the same value across systems — Root cause: divergent token_namespace values between the structured and unstructured detection paths. Fix: scope the namespace to the subject, not the source system, so one value maps to one surrogate and linkage reconciles.

Held value deleted when the hold was still active — Root cause: hold_action overridden to HARD_DELETE, or the hold flag not propagated to the classifier. Fix: keep hold_action at NULLIFY, verify the RETAINED_UNDER_HOLD role is set upstream, and only schedule the deferred hard_delete when the hold is released.

Frequently Asked Questions

Why does the model forbid erasure from resolving to tokenization?

Because tokenization is reversible. It stores the original value in a vault under a surrogate, and the vault holder can restore it, which means the controller still holds the personal data. GDPR Art. 17 requires erasure to put the data beyond recovery, so a reversible technique cannot satisfy it. The RedactionDecision validator makes the pairing a hard failure at construction and on deserialization, so no configuration change or future edit can quietly route a deletion through the vault.

When is tokenization the correct choice, then?

On the access or portability path, for a linkage key — a value whose job is to join the subject’s records across systems without being disclosed to anyone. Tokenizing it preserves the ability to reconcile records while removing the raw identifier from the output, which is exactly the Art. 4(5) definition of pseudonymisation. Tokenization is a legitimate, useful technique; it is simply never a valid answer to an erasure request.

How does the function keep a third party's data out of an access export?

It branches on data role, not field type. A value tagged THIRD_PARTY routes to irreversible masking regardless of which column or document it came from, satisfying the Art. 15(4) duty not to adversely affect the rights and freedoms of others, while the subject’s own data routes to format-preserving masking so the subject still receives a readable copy. An unclassifiable value falls through to the fail-safe mask so the export over-redacts rather than leaking.

What does the pipeline do when erasure meets a legal hold?

It selects NULLIFY instead of HARD_DELETE: the value is made unusable in live systems while the retained copy is preserved under the hold, and the specific retention basis is recorded. This is still an irreversible outcome for the live data, so the Art. 17 invariant holds. When the hold is released, the deferred hard deletion is scheduled and re-verified so the subject’s right is honored as soon as it legally can be.