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:
- 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.
IAuditableEntitycarriesDeletedOnandDeletedBybesideCreatedOn/ByandLastModifiedOn/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, soAuditSaveChangesInterceptorstamps them from theIsDeletedtransition (.../MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:92-106, called at:64and: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 asCreatedOn/Bysurvive every update (AuditSaveChangesInterceptor.cs:13-19,:99-102; the domain-side note atAuditableBaseEntity.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 oneResultviaResult.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 anAlreadyDeletederror a caller cannot act on (:283-286, reasoning at:251-259). An aggregate composes it asResult.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, whoseAllowedHardDeleteTypesis 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).
- The deletion is stamped like any other audit fact.
- 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, callsAnonymize()(idempotent, returnsResult), 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-GCMEncryptedStringConverter. - Outbox retention is bounded.
OutboxCleanupServicepurges processed outbox rows older thanOutbox:RetentionDays(default 7; set0to disable) across every relational data source, so event payloads are not retained indefinitely. - 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 throughPiiConventionTestsBase) fails the build if any Domain entity declaring a[Pii]property does not implementIAnonymizable, so "this aggregate holds personal data but has no erasure path" is caught by tooling, not by review. The companionPiiRedactor(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 implementIAnonymizablefails 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/DeletedBystamps 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.