Audit & soft-delete
What it is
Two cross-cutting data patterns built on the entity base classes:
- Audit logging — meaningful business moments are recorded as immutable
audit_eventsrows. This is selective and manually invoked, not an automatic shadow of every write. - Soft-delete — most entities are never physically removed; they carry an
IsDeletedflag, and the global query filter hides deleted rows.
The page status is partial because of the audit half: it is live but covers only the writes that explicitly call it.
Why it's built this way
Audit is opt-in by design. Rather than an EF SaveChanges interceptor mirroring every change, services call IAuditLogger.LogAsync at the points that matter (a publish, an approval, a config change). This keeps audit rows meaningful — business facts, not a row-level changelog — and was the deliberate decision (sheet 01 §how-it-works, MIQ005_Report.md §1). The trade-off, and the thing to not get wrong: a write with no explicit call is not audited.
AuditEvent is append-only and intentionally not an AuditableEntity — its id is bigint for volume, and it has no update/delete, no soft-delete, no RowVersion. Audit rows are immutable facts (sheet 01 §decisions, AuditEvent.cs, MIQ005_Report.md §1). Append-only is currently application-enforced only — there is no DB INSERT-only role yet (deferred post-MVP, MIQ005_Report.md §6.3).
Soft-delete preserves history and referential safety: deleting a user, for instance, doesn't shred their audit trail (the actor_user_id FK is ON DELETE SET NULL, with actor_username denormalised to survive) (sheet 01 edge-cases, MIQ105…cs:63-69).
How it works
Audit
AuditLogger.LogAsyncwrites oneaudit_eventsrow and swallows failures — audit never blocks the business operation (sheet 01 §rules,AuditLogger.cs:30-65).- It is manually invoked from individual services; ~30 services inject
IAuditLogger. There is no audit SaveChanges interceptor (sheet 01 §build-status). - The write contract: BU =
tenant.BusinessUnitId; actor defaults totenant.UserId/tenant.Username ?? "system"; IP + correlation fromHttpContext;detailsis JSON-serialised (AuditLogger.cs:30-65). AuditEventfields includeevent_type,entity_type,entity_id,action,actor_user_id?(FK→users,ON DELETE SET NULL),actor_username,occurred_at,correlation_id?,reason?,details jsonb?,severity(freevarchar(20), default "Info") (sheet 01 §entities,AuditEvent.cs:3-20).
Soft-delete
- The base
AuditableEntitycarriesIsDeleted,DeletedAt?,DeletedBy?, plusRowVersion(optimistic concurrency) andIsActive(sheet 01 §entities,AuditableEntity.cs:3-15). - The global query filter is
!IsDeleted && (BusinessUnitId == t.BusinessUnitId || t.IsSuperAdmin)— so soft-deleted rows are invisible to normal reads (sheet 01 §rules,ManpowerIQDbContext.cs:124-319). Soft-delete is theIsDeletedhalf; tenant isolation is the other (see Multi-tenancy). - Unique constraints are filtered on
is_deleted = false, so a code can be reused after its holder is soft-deleted (sheet 01 §entities, e.g.Permission.codeUNIQUE filtered).
Gotchas / constraints
- Audit is selective/manual — do not claim "every change is audited." No interceptor exists; an unaudited write is silent (sheet 01 MUST-NOT #3). A new write that should be audited must call
IAuditLoggerexplicitly. AuditEventdoes not inheritAuditableEntity— noIsDeleted,RowVersion,IsActive,CreatedBy;idisbigint(sheet 01 MUST-NOT #5).- Soft-delete vs status are different. Cancelling a
LeaveRequestsetsStatus = Cancelled, notIsDeleted; soft-delete is reserved for admin/PII removal (sheet 01 edge-cases,ManpowerIQDbContext.cs:296-300). severityis free text, not an enum. Docs list "Info/Warning/Critical" but "Error" is used in practice (sheet 01 discrepancies,AllocationRuleEngine.cs:17).RowVersion(bytea) needs a Postgres-side default'\x'::bytea; a whole sweep of "RowVersionDefault" migrations exists because older insert paths failed without it (sheet 01 edge-cases,UserConfiguration.cs:29-31).
Build status
- Soft-delete + partial-unique-index pattern — Available, universal across
AuditableEntityrows (sheet 01 §build-status). - Audit logging — Partial: live but selective/manual (no all-writes interceptor); append-only is application-enforced only, no DB INSERT-only role yet (sheet 01 §build-status,
MIQ005_Report.md §6.3).
Related
- Multi-tenancy — the soft-delete flag is half of the same query filter.
- Clean Architecture — why audit is an explicit call, not a pipeline behaviour.
- Fact sheet: 01 (foundation).