Implementing SLA Extension Workflows

Extending a Data Subject Request (DSR) deadline is not a matter of pushing a date forward in a database — it is a gated legal transition that is only valid if the request qualifies, the data subject is notified with reasons, and the whole decision is recorded as evidence. Within the broader 30-Day vs 45-Day SLA Mapping framework, and the wider DSR Architecture & Intake Routing pipeline it belongs to, this page implements the extension as a state machine with hard preconditions rather than an editable field. The engineers who need it are privacy and platform teams whose base deadlines — computed by the sibling Computing DSR Deadlines Across Time Zones guide — are about to lapse on genuinely complex requests. The regulatory shape is asymmetric: GDPR Art. 12(3) permits a further two months for complex or numerous requests, but the controller must inform the data subject within the original one month, with reasons for the delay; CCPA §1798.130(a)(2)(A) permits a single 45-day extension “when reasonably necessary”, provided the consumer is given notice within the first 45 days. An extension granted without that notice is not an extension at all — it is a breach with extra steps.

An extension is a gated transition: eligibility must pass, the deadline is recomputed from the original anchor, the subject is notified with reasons, and only then does an audit event seal the new deadline.

SLA extension as a gated state transition with a notice guard A request in the Processing state reaches an eligibility gate that asks whether the request is complex or numerous. A simple request is rejected down to a no-extension outcome that keeps the base deadline. An eligible request proceeds to recompute its deadline from the original attestation anchor, then to a notify-subject step that must deliver reasons within the statutory notice window. Successful notification transitions the request to the Extended state. If reasons are not delivered in the window, a guard diverts the transition to a void outcome, because an unnotified extension is invalid. The notify step also emits an audit event into the evidence store. Processing Recompute fromoriginal anchor Notify subjectwith reasons Extended No extension(base deadline) Void Audit event→ evidence complex? yes no no notice
The extension state machine: eligibility gates the transition, the deadline is recomputed from the original anchor, and the notice guard voids any extension the data subject was not told about in time.

Prerequisites

This implementation targets Python 3.11+ and reuses the deadline arithmetic established elsewhere in the SLA layer. You will need:

  • pydantic (v2) — to model the extension decision as a validated, immutable record, using v2 idioms (model_config = ConfigDict(...), field_validator, model_validate). Pydantic v1 patterns are not supported.
  • python-dateutilrelativedelta for the GDPR two-month recomputation, so the extended deadline is a calendar-correct date rather than a fixed day count.
  • A deadline function — the sla_deadline/deadline logic from Computing DSR Deadlines Across Time Zones, so the extension recomputes from the same UTC-anchored base rather than inventing new arithmetic.

The workflow assumes a request that already has a verified attested_at and a computed base deadline, and it emits its audit event into the Audit Logging & Compliance Evidence ledger so the extension is provable years later.

Step-by-Step Implementation

Step 1 — Model the extension decision

The decision is a record, not a mutation. Modeling it as an immutable Pydantic v2 object forces every field a regulator would ask about — who requested it, on what ground, when the subject was told — to be present before the transition can fire.

from datetime import datetime
from enum import Enum

from pydantic import BaseModel, ConfigDict, field_validator


class Framework(str, Enum):
    GDPR = "GDPR"
    CCPA = "CCPA"
    CPRA = "CPRA"


class ExtensionGround(str, Enum):
    COMPLEX = "complex"
    NUMEROUS = "numerous"


class ExtensionDecision(BaseModel):
    """An immutable record of a proposed SLA extension."""

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

    request_id: str
    framework: Framework
    attested_at: datetime
    base_deadline: datetime
    ground: ExtensionGround
    decided_at: datetime

    @field_validator("attested_at", "base_deadline", "decided_at")
    @classmethod
    def must_be_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("extension timestamps must be timezone-aware")
        return v

Step 2 — Gate eligibility

An extension is lawful only for a request that is genuinely complex or numerous, and only if the decision is made before the base deadline lapses. A simple request routed through this gate is rejected, not silently extended — extending a simple request is itself a compliance failure.

def is_eligible(decision: ExtensionDecision) -> tuple[bool, str]:
    """Return (eligible, reason). Fail closed on any missing precondition."""
    if decision.decided_at >= decision.base_deadline:
        return False, "decision made after base deadline lapsed"
    if decision.ground not in (ExtensionGround.COMPLEX, ExtensionGround.NUMEROUS):
        return False, "ground is not complex or numerous"
    return True, "eligible: complex or numerous request within window"

The predicate returns a reason string in both directions so the rejection is as auditable as the approval — a regulator reviewing a denied extension can see exactly why it was refused.

Step 3 — Recompute the deadline from the original anchor

The single most important rule of extensions: they add to the statutory anchor, never to “now”. GDPR grants two further months on top of the original one-month window; CCPA/CPRA grant one further 45-day period on top of the original 45 days. Recomputing from attested_at — not from the moment a reviewer clicked approve — keeps the extension honest.

from datetime import timedelta

from dateutil.relativedelta import relativedelta


def extended_deadline(decision: ExtensionDecision) -> datetime:
    """Extend from the original anchor: GDPR +2 months, CCPA/CPRA +45 days."""
    anchor = decision.attested_at
    if decision.framework is Framework.GDPR:
        # Original one month plus two further months = three calendar months.
        return anchor + relativedelta(months=+3)
    # CCPA/CPRA: original 45 days plus one further 45-day period.
    return anchor + timedelta(days=90)

Because the recomputation is a pure function of the anchor and the framework, an extension granted on day 20 of a GDPR request still yields the same three-calendar-month deadline it would have on day 2 — the clock never resets to the approval moment.

Step 4 — Notify the subject with reasons and build the notice record

The notice is the hinge of the whole workflow. GDPR Art. 12(3) requires the controller to inform the data subject within the original month, with the reasons for the delay; CCPA requires notice within the first 45 days. The notice record captures the deadline for that notice and whether it was met.

def notice_deadline(decision: ExtensionDecision) -> datetime:
    """The latest instant the subject may be told, per framework."""
    if decision.framework is Framework.GDPR:
        return decision.attested_at + relativedelta(months=+1)
    return decision.attested_at + timedelta(days=45)


class ExtensionNotice(BaseModel):
    """Evidence that the subject was informed of the extension in time."""

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

    request_id: str
    reasons: str
    notified_at: datetime
    notice_due_by: datetime

    @field_validator("reasons")
    @classmethod
    def reasons_required(cls, v: str) -> str:
        if len(v.strip()) < 10:
            raise ValueError("Art. 12(3) requires substantive reasons for delay")
        return v

    @property
    def sent_in_time(self) -> bool:
        return self.notified_at <= self.notice_due_by

Step 5 — Apply the gate and emit an audit event

The transition composes the previous steps: eligibility, recomputation, in-time notice. Only when all three hold does the request move to EXTENDED; otherwise it stays on its base deadline or is voided. Every outcome emits a structured event into the audit ledger.

import hashlib
import json
from datetime import timezone


def emit_audit_event(request_id: str, outcome: str, detail: dict) -> dict:
    """Append a structured extension event destined for the evidence ledger."""
    body = {
        "request_id": request_id,
        "event": "sla_extension",
        "outcome": outcome,
        "detail": detail,
        "at": datetime.now(timezone.utc).isoformat(),
    }
    encoded = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
    body["hash"] = hashlib.sha256(encoded).hexdigest()
    return body


def apply_extension(
    decision: ExtensionDecision, notice: ExtensionNotice
) -> tuple[str, datetime, dict]:
    """Gated transition. Returns (state, effective_deadline, audit_event)."""
    eligible, reason = is_eligible(decision)
    if not eligible:
        event = emit_audit_event(decision.request_id, "REJECTED", {"reason": reason})
        return "PROCESSING", decision.base_deadline, event

    if not notice.sent_in_time:
        event = emit_audit_event(
            decision.request_id, "VOID_NO_NOTICE",
            {"notice_due_by": notice.notice_due_by.isoformat()},
        )
        return "BREACH_RISK", decision.base_deadline, event

    new_deadline = extended_deadline(decision)
    event = emit_audit_event(
        decision.request_id, "EXTENDED",
        {"ground": decision.ground.value, "new_deadline": new_deadline.isoformat()},
    )
    return "EXTENDED", new_deadline, event

Configuration Reference

Parameter Type Default Compliance note
ground ExtensionGround required Must be complex or numerous; GDPR Art. 12(3) permits extension only on these grounds.
GDPR extension relativedelta months=+2 on base Two further months added to the one-month base (three months total from anchor).
CCPA/CPRA extension timedelta days=45 on base One further 45-day period (90 days total from anchor); §1798.130(a)(2)(A).
notice_due_by datetime anchor + 1 month / 45 days Latest instant the subject may be informed; a later notice voids the extension.
reasons str required, ≥ 10 chars Substantive reasons for delay are mandatory under Art. 12(3).
max extensions int 1 A single extension is permitted; a second is not lawful under either framework.
decided_at datetime required, tz-aware Must precede the base deadline; a decision after lapse cannot extend it.

Verification

The tests pin the three ways an extension can be unlawful — no notice, wrong request type, double extension — alongside the happy path. A regression that lets any of the failure cases pass must break the build.

from datetime import datetime, timedelta, timezone

UTC = timezone.utc


def _decision(**kw):
    base = dict(
        request_id="dsr_1", framework=Framework.GDPR,
        attested_at=datetime(2026, 3, 1, tzinfo=UTC),
        base_deadline=datetime(2026, 4, 1, tzinfo=UTC),
        ground=ExtensionGround.COMPLEX,
        decided_at=datetime(2026, 3, 20, tzinfo=UTC),
    )
    base.update(kw)
    return ExtensionDecision(**base)


def test_gdpr_extension_is_three_months_from_anchor():
    d = _decision()
    assert extended_deadline(d).date().isoformat() == "2026-06-01"


def test_extension_voids_without_timely_notice():
    d = _decision()
    late = ExtensionNotice(
        request_id="dsr_1", reasons="volume of records is very large",
        notified_at=datetime(2026, 4, 10, tzinfo=UTC),   # after the 1-month notice window
        notice_due_by=notice_deadline(d),
    )
    state, deadline, _ = apply_extension(d, late)
    assert state == "BREACH_RISK" and deadline == d.base_deadline


def test_simple_request_is_rejected_not_extended():
    d = _decision(ground=ExtensionGround.COMPLEX)
    # Force ineligibility by deciding after the base deadline lapsed.
    d = _decision(decided_at=datetime(2026, 4, 2, tzinfo=UTC))
    state, deadline, _ = apply_extension(d, _good_notice(d))
    assert state == "PROCESSING" and deadline == d.base_deadline


def _good_notice(d):
    return ExtensionNotice(
        request_id=d.request_id, reasons="cross-system discovery is complex",
        notified_at=datetime(2026, 3, 25, tzinfo=UTC),
        notice_due_by=notice_deadline(d),
    )

Every path also returns an audit event; assert in an integration test that the EXTENDED outcome writes a hash-linked record into the ledger, so the extension is provable under GDPR Art. 5(2) accountability, not merely applied.

Troubleshooting

Extending without informing the subject. Symptom: the deadline moves but no notice record exists. Cause: the recompute step ran without gating on sent_in_time. Fix: route every extension through apply_extension, which voids the transition (VOID_NO_NOTICE) when the notice missed its window — the most common and most serious extension defect.

Extending a simple request. Symptom: routine access requests receive three-month deadlines. Cause: the eligibility predicate was bypassed or the ground defaulted. Fix: require an explicit complex or numerous ground and fail closed in is_eligible; an extension is a justified exception, not a default.

Double extension. Symptom: a request receives two successive extensions and lands beyond 90 days. Cause: no cap on the number of extensions applied. Fix: enforce max extensions = 1; neither GDPR nor CCPA authorizes a second extension, so a request that needs more time must be escalated to human review rather than re-extended.

Recomputing from “now” instead of the anchor. Symptom: an extension granted late in the window pushes the deadline further than the statute allows. Cause: extended_deadline anchored to decided_at. Fix: recompute strictly from attested_at; the extension adds to the statutory anchor, never to the approval moment.

Notice sent, but without reasons. Symptom: a notice record exists but the extension is still challenged. Cause: the notice omitted substantive reasons for the delay. Fix: validate reasons at the model boundary (Art. 12(3) requires reasons), and store the reason text in the audit event so the justification is preserved.

Frequently Asked Questions

How much extra time does an SLA extension actually grant?

Under GDPR Art. 12(3), up to two further months on top of the original one month, for a total of three months from the verification anchor, and only for complex or numerous requests. Under CCPA §1798.130(a)(2)(A), a single further 45-day period on top of the original 45 days, for 90 days total, when reasonably necessary. Both are computed from the original anchor, never from the approval moment.

Why does an extension require notifying the data subject?

Because the statute makes notice a precondition, not a courtesy. GDPR Art. 12(3) requires the controller to inform the subject within the original one month, with the reasons for the delay; CCPA requires notice within the first 45 days. An extension applied without timely notice is legally void, which is why the workflow gates the transition on sent_in_time and routes an un-notified extension to a breach-risk state rather than extending the deadline.

Can a DSR deadline be extended more than once?

No. Both frameworks authorize a single extension — two further months under GDPR, one 45-day period under CCPA/CPRA. A request that cannot be completed even within the extended window should be escalated to human review and, where appropriate, partially fulfilled, rather than extended a second time. The configuration caps extensions at one for exactly this reason.

What audit evidence should an extension leave behind?

A structured, hash-linked event recording the ground for the extension, the recomputed deadline, and proof the subject was notified in time. Feeding that event into the Audit Logging & Compliance Evidence ledger satisfies the GDPR Art. 5(2) duty to demonstrate compliance, so the extension is provable during a later investigation rather than merely asserted.