to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-005: Soft-Delete vs. Right-to-Erasure

Status

Accepted. Updated 2026-06-27 (documented the [Pii]IAnonymizable build-time guard and PiiRedactor). Updated 2026-08-26 (DeletedOn/DeletedBy stamps, the DeleteChildren cascade helper, and a hard-delete fitness rule).

Context

The framework's default deletion model is soft-delete: AuditableBaseEntity.Delete() sets IsDeleted = true and EF Core global query filters exclude the row from normal queries. The row (including any personal data it holds) stays in the database indefinitely, which is exactly what audit, referential integrity, and "undelete" (BR-135) require.

This conflicts with data-subject erasure rights (GDPR Art. 17 right-to-be-forgotten, CCPA deletion): when a person requests deletion, their personal data must actually be removed or anonymized, not merely hidden. Soft-delete alone is therefore non-compliant for personal data, and a consumer app's published privacy policy (e.g. a "we delete your data within 30 days" promise) cannot be honored by soft-delete.

A second source of retained personal data is the outbox: processed OutboxMessage rows hold serialized event payloads that may contain personal data and were previously never purged (ADR-003).

Decision

Separate the two concerns and provide an extension point for each, rather than overloading soft-delete:

  1. Soft-delete stays the default for lifecycle/state management (hide + retain + undelete). It is explicitly not a privacy mechanism. Three things make the lifecycle half complete rather than merely a flag:
    • The deletion is stamped like any other audit fact. IAuditableEntity carries DeletedOn and DeletedBy beside CreatedOn/By and LastModifiedOn/By (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:26,29, implemented at .../Entities/AuditableBaseEntity.cs:39,45), so "when was this deleted, and by whom" is answerable from the row rather than from an audit-trail lookup (ADR-075). The domain does not write them: the clock and the current user live in infrastructure, so AuditSaveChangesInterceptor stamps them from the IsDeleted transition (.../MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:92-106, called at :64 and :72, prior value read at :84-85) and clears them on the reverse one (Undelete, BR-135, AuditableBaseEntity.cs:89). Driving it from the transition rather than from the value is what keeps a later update to an already-deleted row from re-stamping it, exactly as CreatedOn/By survive every update (AuditSaveChangesInterceptor.cs:13-19, :99-102; the domain-side note at AuditableBaseEntity.cs:60-64).
    • Cascading to children is a framework helper, not a loop per aggregate. AuditableAggregateRootEntity.DeleteChildren<TChild, TChildId>(children) (.../Domain/Entities/AuditableAggregateRootEntity.cs:273-292) deletes every still-active child and aggregates the failures into one Result via Result.Combine (ADR-013, :291). Already-deleted children are skipped rather than reported, so re-deleting a parent is idempotent with respect to its children instead of surfacing an AlreadyDeleted error a caller cannot act on (:283-286, reasoning at :251-259). An aggregate composes it as Result.Combine(DeleteChildren<OrderLine, OrderLineIdentifierType>(_lines), base.Delete()), so a failing child aborts the whole cascade before the root is touched (:262).
    • A hard delete is a reviewable exception, enforced by a fitness rule. ArchitectureRules.HardDeletesOnlyInAllowedTypes (.../Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.SoftDelete.cs:62, surfaced through .../Bases/SoftDeleteEnforcementTestsBase.cs:17,29-31, whose AllowedHardDeleteTypes is empty by default and therefore bans hard deletes outright until a repo names its exceptions, :27) fails the build when EF Core's erasing members (DbSet.Remove/RemoveRange, DbContext.Remove/RemoveRange, ExecuteDelete/ExecuteDeleteAsync: ArchitectureRules.SoftDelete.cs:10-11, scoped to the entity-set declaring types at :18-24) are called from a type outside the repo's allowlist, which is the short list of places where erasing a row IS the requirement: retention purge jobs, outbox and audit-trail cleanup, and the erasure handlers below (:26-34, SoftDeleteEnforcementTestsBase.cs:21-26). Neither NetArchTest nor reflection can see a call inside a method body, so the rule reads IL through the Mono.Cecil that NetArchTest already carries and matches callees by full name, which keeps the testing package's zero-reference stance toward EF Core (ArchitectureRules.SoftDelete.cs:36-43, the callee test at :124). It sees only direct calls: a hard delete reached through a repo-owned interface is caught at the implementing type, so that implementation goes on the allowlist and the abstraction stays usable (:51-56).
  2. Erasure is an explicit, additive capability. Aggregates that store personal data implement IAnonymizable (MMCA.Common.Domain.Interfaces). An application-layer erasure handler loads the aggregate, calls Anonymize() (idempotent, returns Result), and saves: overwriting personal fields in place so foreign keys and the audit trail survive. Fields that must remain retrievable are persisted through the AES-256-GCM EncryptedStringConverter.
  3. Outbox retention is bounded. OutboxCleanupService purges processed outbox rows older than Outbox:RetentionDays (default 7; set 0 to disable) across every relational data source, so event payloads are not retained indefinitely.
  4. A build-time guard backs the per-entity opt-in. Domain properties holding data-subject personal data are marked [Pii] (MMCA.Common.Domain.Attributes.PiiAttribute, property-targeted). A fitness function (ArchitectureRules.EntitiesWithPiiImplementAnonymizable, surfaced through PiiConventionTestsBase) fails the build if any Domain entity declaring a [Pii] property does not implement IAnonymizable, so "this aggregate holds personal data but has no erasure path" is caught by tooling, not by review. The companion PiiRedactor (MMCA.Common.Domain.Privacy) masks the same [Pii]-marked members when an entity is written to a log or telemetry attribute, so personal data does not leak through diagnostics.

The framework provides the extension points (IAnonymizable, OutboxCleanupService, PiiRedactor) and a build-time guard (the [Pii]IAnonymizable fitness rule); each consumer app owns the policy: which properties are [Pii], the erasure orchestration/endpoint (data-subject request handling), and any data-subject access/export endpoint, because the personal-data model lives in the consumer (e.g. ADC's User).

Rationale

  • Right tool per concern: soft-delete answers "is this record active?"; erasure answers "has this person's data been removed?". Conflating them (e.g. hard-deleting inside Delete()) would break audit, undelete, and referential integrity.
  • Audit-preserving: anonymize-in-place keeps the row and its audit fields, satisfying both erasure and accountability obligations simultaneously.
  • Idempotent + Result-based: matches the framework's domain conventions and tolerates retried erasure requests.
  • Bounded retention: the cleanup service closes the "outbox grows forever / retains PII forever" gap noted in ADR-003 without changing delivery semantics.

Trade-offs

  • Erasure is opt-in per entity, but the opt-in is guarded: an aggregate exposing a [Pii]-marked property that does not implement IAnonymizable fails the architecture fitness test, so the "holds personal data but is not erasable" gap surfaces at build time rather than in a manual personal-data inventory. The residual risk shifts to marking: an entity that holds personal data on an unmarked property is invisible to the guard, so consumers still own the discipline of applying [Pii] to genuine data-subject fields.
  • Anonymization is irreversible by design and is not the same operation as undelete.
  • The framework cannot, on its own, make a consumer compliant: the consumer must still wire the erasure handler, the data-subject request flow, and access/export. This ADR provides the extension points, not the policy.
  • The default 7-day outbox retention is a behavior change: consumers upgrading the framework begin purging processed outbox rows older than 7 days unless they set Outbox:RetentionDays = 0.
  • The DeletedOn/DeletedBy stamps are two new nullable columns on every auditable table, so adopting the framework version that introduced them costs one scaffolded migration per consumer database. They are additive (expand only, no ADR-057 override needed) and they are not backfilled: rows soft-deleted before the migration keep null stamps forever, so the pair answers "who deleted this" only from that point on.
  • The fitness rule turns "we soft-delete, mostly" into a reviewed inventory, not into a guarantee. It sees direct IL calls in the assemblies the repo's architecture map registers, so raw SQL, a stored procedure, or a delete issued from an unmapped assembly is invisible to it, and an allowlist entry covers every erasing call in the named type rather than the one that was reviewed.