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.
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-dateutil—relativedeltafor 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/deadlinelogic 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.
Related
- 30-Day vs 45-Day SLA Mapping — the deadline-enforcement layer that provisions extension workflows.
- Computing DSR Deadlines Across Time Zones — the base-deadline arithmetic an extension recomputes from.
- Audit Logging & Compliance Evidence — where the extension’s audit events are chained into tamper-evident evidence.
- Building a Jurisdiction-Aware Intake Router in Python — resolving the framework whose extension rules apply.
- DSR Architecture & Intake Routing — the parent pipeline this extension stage plugs into.