Skip to content

Personal Data Deletion

Two modes are supported — immediate (default) and deferred with an opt-in cooling-off period:

flowchart TD
    A["POST /privacy/deletions"] --> B{defer?}
    B -->|true| G[DeletionDeferredEto]
    G --> H["PersonalDataDeletionSaga starts"]
    H --> I["Schedule reminder → Reminder email"]
    H --> J["Schedule deadline"]
    J --> L{Cancelled?}
    L -->|Yes| O["Saga completed, data preserved"]
    L -->|No| DL[Deadline reached]
    B -->|false| DL
    DL --> Q{"registered<br/>providers?"}
    Q -->|none| E
    Q -->|"one or more"| X["Mark Executing<br/>+ DeletionExecutedEto (grace ended)<br/>+ Confirmation email"]
    X --> Y["Fan out PersonalDataDeletionRequestedEto<br/>schedule ack timeout"]
    Y --> Z{"all providers<br/>acknowledged?<br/>(PersonalDataDeletedEto)"}
    Z -->|yes| E["Mark Executed<br/>(GDPR Art. 17 proof)"]
    Z -->|"timeout elapses"| P["Mark PartiallyExecuted<br/>persist MissingProviders<br/>emit stuck metric + warning"]

The user-facing DeletionExecutedEto still fires the moment the grace period ends — it confirms the cooling-off window closed, not that every provider has finished. The tracker only reaches the terminal Executed state once the provider acknowledgement fan-in completes (see Provider acknowledgement fan-in).

When the user sets defer: true, the deletion is postponed for a configurable grace period (default from the regulation profile). During this period the user can cancel via POST /privacy/deletions/{requestId}/cancel. A reminder email is sent a few days before the deadline. A daily safety-net job (DeletionDeadlineEnforcerJob) catches any requests that the saga might have missed.

MethodRouteOperationPermission
POST/privacy/deletionsRequestPrivacyDeletionPrivacy.Deletions.Execute
POST/privacy/deletions/{requestId}/cancelCancelPrivacyDeletionPrivacy.Deletions.Execute
GET/privacy/deletions/{requestId}GetPrivacyDeletionStatus(owner only)
GET/privacy/deletionsListPrivacyDeletions(owner only)

POST /privacy/deletions returns 202 Accepted. Filing a second deletion while a deferred request is already pending returns 409 Conflict — cancel the existing one first.

Grace periods default from the regulation profile. Override globally or per-regulation:

{
"Privacy": {
"DefaultGracePeriodDays": 30,
"MaxGracePeriodDays": 90,
"ReminderDaysBefore": 3,
"DeletionAcknowledgementTimeoutMinutes": 720,
"RegulationOverrides": {
"BR_LGPD": { "DefaultGracePeriodDays": 15 },
"US_CCPA": { "DefaultGracePeriodDays": 45, "MaxGracePeriodDays": 90 }
}
}
}

DeletionAcknowledgementTimeoutMinutes (default 720) bounds how long the saga waits for provider acknowledgements after fan-out before marking the request PartiallyExecuted — see Provider acknowledgement fan-in. The grace-period keys are independent of it.

Deferred deletion state needs a request tracker. The EF Core default registers both the export and deletion trackers against PrivacyDbContext in one call:

services.AddGranitPrivacy(privacy => privacy
.UseEntityFrameworkCoreTrackers()); // export + deletion trackers

Apps that persist deletion state elsewhere supply their own implementation via the generic hook privacy.UseDeletionRequestTracker<TStore>().

Granit.Privacy.Notifications covers the full lifecycle of a deletion request. Every notification renders against application-provided templates and can use the {{ privacy }} global context to embed the controller / DPO contact.

Notification nameTriggerChannelsSeverityPurpose
privacy.deletion_acknowledgedPersonalDataDeletionRequestedEtoEmailInfoReceipt acknowledgement (GDPR Art. 12 §3 — paper trail of “without undue delay” response)
privacy.deletion_deferred_confirmedDeletionDeferredEtoEmailInfoConfirms the new scheduled deletion date when the user opts to defer
privacy.deletion_reminderDeletionReminderDueEtoEmail + InAppWarningJ-N reminder before the scheduled deadline so the user can still cancel
privacy.deletion_cancelledDeletionCancelledEtoEmailInfoConfirms revocation of a deferred deletion (data is retained)
privacy.deletion_confirmedDeletionExecutedEtoEmailInfoFinal confirmation that the deletion was executed

The *_acknowledged, *_deferred_confirmed and *_cancelled notifications carry the RequestId, the relevant timestamp(s) and the Regulation code so templates can quote the regulatory deadline directly from the active PrivacyRegulationProfile.

Modules that hold user-keyed aggregates participate in the deletion fan-out by subscribing to PersonalDataDeletionRequestedEto and processing every aggregate where OwnerId == request.UserId. The pattern is generic — any IOwnable module can plug in.

Three choices the handler makes:

  1. Action. What to do with each owned row — SoftDelete (trash, then the existing retention pipeline finishes the job), Anonymized (replace identifying fields and reassign ownership to an anonymised user), Retained (legal hold), or CryptoShredding (destroy the per-entity encryption key). Report the choice on the DeletionAction field of the PersonalDataDeletedEto audit fragment.
  2. Idempotency. Re-delivery of the same PersonalDataDeletionRequestedEto must not double-process rows. Filter at the service layer (e.g. only list active rows) so already-handled rows naturally drop out.
  3. System anchors. If the module has aggregates that must survive the deletion of any individual user (per-tenant root folders, system queues, shared catalog entries), they MUST be filtered at the service contract level — not by an if inside the handler. See the invariant note below.

Example handler — Granit.Documents.Privacy

Section titled “Example handler — Granit.Documents.Privacy”
public class DocumentsPersonalDataDeletionHandler
{
public const string ProviderName = "documents";
// Wolverine discovers the static HandleAsync and wires DI for the parameters.
// Returning a PersonalDataDeletedEto lets Wolverine cascade the acknowledgement
// back to the saga — [SagaIdentity] on RequestId routes it. Build the ack only
// after the erasure succeeds: if a call throws, the return is never reached, no
// ack is published, and Wolverine retries / dead-letters instead.
public static async Task<PersonalDataDeletedEto> HandleAsync(
PersonalDataDeletionRequestedEto request,
IDocumentService documents,
IFolderService folders,
CancellationToken cancellationToken)
{
// 1. Enumerate by OwnerId at the service contract — system anchors (here:
// the tenant root folder) are excluded by the service itself, not by
// a guard inside this handler.
IReadOnlyList<Guid> folderIds = await folders
.ListActiveNonRootIdsByOwnerAsync(request.UserId, cancellationToken);
IReadOnlyList<Guid> documentIds = await documents
.ListActiveIdsByOwnerAsync(request.UserId, cancellationToken);
// 2. Idempotency — re-delivery yields 0 active rows once the cascade ran.
// Still acknowledge (Retained / 0) so the saga's fan-in drains this provider.
if (folderIds.Count == 0 && documentIds.Count == 0)
{
return new PersonalDataDeletedEto(
request.RequestId,
ProviderName,
DeletionAction.Retained,
AffectedRecords: 0,
Details: "no active documents or folders owned by user");
}
// 3. Apply the policy — Documents picks SoftDelete (trash); the F9.2
// empty-trash job promotes to PermanentlyDeleted after the grace period.
int trashedFolders = 0;
foreach (Guid folderId in folderIds)
{
if (await folders.TrashAsync(folderId, cancellationToken) is not null)
{
trashedFolders++;
}
}
int trashedDocuments = 0;
foreach (Guid documentId in documentIds)
{
if (await documents.TrashAsync(documentId, cancellationToken) is not null)
{
trashedDocuments++;
}
}
// 4. Acknowledge — this return is the fan-in signal that lets the saga count
// "documents" as done. DeletionAction.SoftDelete + the count also feed the
// per-provider audit ledger. Until it arrives, the request stays Executing.
return new PersonalDataDeletedEto(
request.RequestId,
ProviderName,
DeletionAction.SoftDelete,
AffectedRecords: trashedFolders + trashedDocuments,
Details: $"trashed {trashedFolders} folder(s) and {trashedDocuments} document(s); tenant root preserved");
}
}

Load the handler’s module so Wolverine discovers it via the assembly scan:

[DependsOn(typeof(GranitDocumentsPrivacyModule))]
public class MyAppModule : GranitModule { }

Multiple modules registering their own privacy handlers compose naturally — each subscribes to PersonalDataDeletionRequestedEto independently.

The saga proves completion the same way the export saga proves assembly — a scatter-gather fan-in over provider acknowledgements. This is what makes a deletion provable for GDPR Art. 17, rather than optimistically “requested”.

At the deadline (or immediately, for a non-deferred request) the saga:

  1. Snapshots the registered providers from IDataProviderRegistry, marks the request Executing, and fans out PersonalDataDeletionRequestedEto.
  2. Publishes the user-facing DeletionExecutedEto immediately — it only confirms the grace period ended, not that erasure finished — and schedules an acknowledgement timeout.
  3. Waits for every expected provider to acknowledge by returning a PersonalDataDeletedEto. The eto carries [SagaIdentity] so Wolverine cascades it back to the saga, routed by RequestId. Only when the last expected provider acknowledges does the request reach the terminal Executed state.

The fan-in is idempotent: duplicate or unregistered acknowledgements are counted for observability but never double-complete the saga. A request with zero registered providers keeps the immediate-Executed fast path.

If the acknowledgement window (DeletionAcknowledgementTimeoutMinutes, default 720 = 12 h) elapses before every provider confirms, the saga marks the request PartiallyExecuted, persists the names of the providers that never acknowledged, and fires a per-provider stuck-deletion metric plus a warning log so DLQ monitoring can alert. The request is not provably complete — an operator reconciles the missing providers before the erasure can be attested for Art. 17.

StateMeaning
DeferredGrace period active; user may still cancel.
CancelledUser cancelled during the grace period; data retained.
ExecutingDeadline reached, fan-out started, awaiting provider acknowledgements.
ExecutedEvery registered provider acknowledged erasure — the only state that proves Art. 17 completion.
PartiallyExecutedAcknowledgement window elapsed with at least one provider missing (permanent failure, dead-letter, or unregistered handler).

GetPrivacyDeletionStatus surfaces the current state and, for PartiallyExecuted, a MissingProviders list. The DeletionDeadlineEnforcerJob safety-net remains a direct-Executed fallback for the case where the saga timer never fires at all.

MetricDescription
granit.privacy.deletion.executedDeletions executed (immediate or after the grace period).
granit.privacy.deletion.acknowledgedPer-provider deletion acknowledgements received by the saga.
granit.privacy.deletion.stuckRequests that timed out with a provider never acknowledging (PartiallyExecuted). Tagged by provider_name so DLQ monitoring can alert on the specific stuck provider.