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 -->|false| C[PersonalDataDeletionRequestedEto]
    C --> D[Providers delete immediately]
    D --> E[DeletionExecutedEto]
    E --> F["Confirmation email"]
    B -->|true| G[DeletionDeferredEto]
    G --> H["PersonalDataDeletionSaga starts"]
    H --> I["Schedule reminder"]
    H --> J["Schedule deadline"]
    I --> K["Reminder email"]
    J --> L{Cancelled?}
    L -->|No| M[PersonalDataDeletionRequestedEto]
    M --> N["Confirmation email"]
    L -->|Yes| O["Saga completed, data preserved"]

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.

| Method | Route | Operation | Permission | | ------ | ----- | --------- | ---------- | | POST | /privacy/deletions | RequestPrivacyDeletion | Privacy.Deletions.Execute | | POST | /privacy/deletions/{requestId}/cancel | CancelPrivacyDeletion | Privacy.Deletions.Execute | | GET | /privacy/deletions/{requestId} | GetPrivacyDeletionStatus | (owner only) | | GET | /privacy/deletions | ListPrivacyDeletions | (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,
"RegulationOverrides": {
"BR_LGPD": { "DefaultGracePeriodDays": 15 },
"US_CCPA": { "DefaultGracePeriodDays": 45, "MaxGracePeriodDays": 90 }
}
}
}

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 name | Trigger | Channels | Severity | Purpose | | ----------------- | ------- | -------- | -------- | ------- | | privacy.deletion_acknowledged | PersonalDataDeletionRequestedEto | Email | Info | Receipt acknowledgement (GDPR Art. 12 §3 — paper trail of “without undue delay” response) | | privacy.deletion_deferred_confirmed | DeletionDeferredEto | Email | Info | Confirms the new scheduled deletion date when the user opts to defer | | privacy.deletion_reminder | DeletionReminderDueEto | Email + InApp | Warning | J-N reminder before the scheduled deadline so the user can still cancel | | privacy.deletion_cancelled | DeletionCancelledEto | Email | Info | Confirms revocation of a deferred deletion (data is retained) | | privacy.deletion_confirmed | DeletionExecutedEto | Email | Info | Final 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.
public static async Task HandleAsync(
PersonalDataDeletionRequestedEto request,
IDocumentService documents,
IFolderService folders,
IDistributedEventBus bus,
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.
if (folderIds.Count == 0 && documentIds.Count == 0)
{
await bus.PublishAsync(new PersonalDataDeletedEto(
request.RequestId,
ProviderName,
DeletionAction.Retained,
AffectedRecords: 0,
Details: "no active documents or folders owned by user"), cancellationToken);
return;
}
// 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. Audit ack — DeletionAction.SoftDelete + the count keep the privacy
// saga's per-provider receipt ledger accurate.
await bus.PublishAsync(new PersonalDataDeletedEto(
request.RequestId,
ProviderName,
DeletionAction.SoftDelete,
AffectedRecords: trashedFolders + trashedDocuments,
Details: $"trashed {trashedFolders} folder(s) and {trashedDocuments} document(s); tenant root preserved"),
cancellationToken);
}
}

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.