Skip to content

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_events rows. This is selective and manually invoked, not an automatic shadow of every write.
  • Soft-delete — most entities are never physically removed; they carry an IsDeleted flag, 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.LogAsync writes one audit_events row 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 to tenant.UserId / tenant.Username ?? "system"; IP + correlation from HttpContext; details is JSON-serialised (AuditLogger.cs:30-65).
  • AuditEvent fields include event_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 (free varchar(20), default "Info") (sheet 01 §entities, AuditEvent.cs:3-20).

Soft-delete

  • The base AuditableEntity carries IsDeleted, DeletedAt?, DeletedBy?, plus RowVersion (optimistic concurrency) and IsActive (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 the IsDeleted half; 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.code UNIQUE 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 IAuditLogger explicitly.
  • AuditEvent does not inherit AuditableEntity — no IsDeleted, RowVersion, IsActive, CreatedBy; id is bigint (sheet 01 MUST-NOT #5).
  • Soft-delete vs status are different. Cancelling a LeaveRequest sets Status = Cancelled, not IsDeleted; soft-delete is reserved for admin/PII removal (sheet 01 edge-cases, ManpowerIQDbContext.cs:296-300).
  • severity is 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 patternAvailable, universal across AuditableEntity rows (sheet 01 §build-status).
  • Audit loggingPartial: 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).
  • 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).