Personal Data Deletion
Deletion modes
Section titled “Deletion modes”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"]
Cooling-off period
Section titled “Cooling-off period”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.
Endpoints
Section titled “Endpoints”| 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.
Configuration
Section titled “Configuration”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 trackersApps that persist deletion state elsewhere supply their own implementation via the
generic hook privacy.UseDeletionRequestTracker<TStore>().
Notifications
Section titled “Notifications”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.
Cascade cleanup for owned entities
Section titled “Cascade cleanup for owned entities”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:
- 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), orCryptoShredding(destroy the per-entity encryption key). Report the choice on theDeletionActionfield of thePersonalDataDeletedEtoaudit fragment. - Idempotency. Re-delivery of the same
PersonalDataDeletionRequestedEtomust not double-process rows. Filter at the service layer (e.g. only list active rows) so already-handled rows naturally drop out. - 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
ifinside 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); }}Wiring
Section titled “Wiring”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.
See also
Section titled “See also”- Privacy overview — module setup and data provider registry
- Data Export — sibling scatter-gather saga
- Regulations —
PrivacyRegulationProfiledriving deadlines - Legal Agreements — consent records preserved alongside deletion
- Crypto-shredding — GDPR erasure without deleting rows
- IOwnable — marker the cascade keys off of
- Notifications module — channel that delivers
*_acknowledged/*_cancelled