Architecture Decision Record
ADR-075: Audit Trail (Same-Transaction Field-Level Change History)
Status
Accepted (2026-08-13; corrected 2026-08-14: the adoption sweep and the ApplicationDbContext line
citations). The implementation lands in the MMCA.Common "enterprise capability wave" release
and is opt-in at three gates: AddAuditTrail(configuration) registers the interceptor, the settings, the
reader and the retention job, AuditTrail:Enabled (default false) maps the table and turns capture on, and
an entity is audited only when it carries the IAuditedEntity marker. Absent registration the interceptor
is not resolved and the whole feature is a no-op.
Revised 2026-09-07 (Store pins the reverse [Pii] convention with a fitness test, and deployed SQL
auditing backstops the in-database trail).
Revised 2026-09-25 (both apps' SQL auditing now records UPDATE and DELETE statements against the trail
table, by different means, and the code anchors are refreshed).
Revised 2026-10-01 (retention has no fallback: without the scheduler nothing is purged; see Revision below).
Context
The framework already answers "who touched this row last". Every AuditableBaseEntity carries
CreatedOn/By and LastModifiedOn/By, stamped by AuditSaveChangesInterceptor on the way into
SaveChangesAsync. What it cannot answer is "what changed". The stamps are a single overwrite: the
current value of LastModifiedBy erases the previous one, so a row that has been edited nine times
carries the ninth editor and nothing else. Soft-delete (ADR-005) preserves the row, not its history.
That gap becomes concrete the moment a regulated or contested question arrives: which administrator moved this ticket's priority, what the price was before yesterday's edit, whether a role assignment predates or postdates an incident. Every consumer in the workspace has the same gap and none of them has built the same answer, which is exactly the shape of a framework concern rather than an application one.
Several questions had no recorded answer:
- Where does the capture run? A repository decorator sees intent but not the change tracker's diff. An application-layer handler sees the command, not the properties EF actually marked modified. A database trigger sees the diff and nothing else: no user, no correlation id, no PII knowledge.
- Does the trail commit with the data or beside it? A trail written after the commit can be lost by a crash in the window; a trail written to a different database cannot share the transaction at all.
- What happens to personal data? A change trail is precisely the store that outlives the erasure of the row it describes, so a naive old-value capture would recreate the personal data that ADR-005 exists to remove.
- What stops it from swamping the database? Auditing every entity in a shipped framework would put every consumer's write path on a multiplier they never asked for.
Decision
A fourth SaveChangesInterceptor, resolved optionally, running last
AuditTrailSaveChangesInterceptor (Infrastructure Persistence/AuditTrail/) joins the interceptors
ApplicationDbContext.OnConfiguring already passes to optionsBuilder.AddInterceptors
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:298-323,
where AuditSaveChangesInterceptor and DomainEventSaveChangesInterceptor are resolved with
GetRequiredService (:298-299) and the tenant and audit-trail interceptors with GetService). The new
one is resolved with GetService, not GetRequiredService (:320): a host that never calls AddAuditTrail
resolves null, nothing is added to the pipeline, and the feature costs nothing.
Registration order is execution order and it is load-bearing. After the wave the sequence is
AuditSaveChangesInterceptor (stamps CreatedBy/On and LastModifiedBy/On), then
TenantSaveChangesInterceptor (ADR-073, stamps TenantId), then DomainEventSaveChangesInterceptor
(writes the outbox rows), then AuditTrailSaveChangesInterceptor last, so the diff it captures sees the
final stamped values rather than a half-populated entity. The audit-trail rows it adds are themselves
never audited. DesignTimeDbContextHelper registers all four, or dotnet ef breaks for every consumer.
AuditTrailEntry is deliberately not an auditable entity
The entity (Infrastructure Persistence/AuditTrail/AuditTrailEntry.cs) does not implement
IAuditableEntity, mirroring OutboxMessage. It therefore has no audit stamps of its own and no
soft-delete query filter. Its columns are Id (Guid), EntityType, EntityKey, PropertyName,
OldValue and NewValue (string, nullable), Operation (Added / Modified / Deleted), ChangedBy,
ChangedOn, CorrelationId, and a nullable TenantId populated when tenancy is active.
Capture is a change-tracker diff committed in the caller's transaction
SavingChangesAsync walks ChangeTracker.Entries(), keeps the entities carrying the marker, compares
OriginalValue against CurrentValue for each modified property, and adds one row per changed property
through context.Set<AuditTrailEntry>().Add(...). The rows go into the same SaveChanges call as the
data, so they commit or roll back with it: the outbox mechanic of ADR-003, applied to a different payload.
Two mechanics are copied verbatim from DomainEventSaveChangesInterceptor:
- A
DiscardAbandonedCaptureequivalent.EnableRetryOnFailurere-runsSavingChangesagainst a change tracker that still holds the previous attempt's Added rows, so without an explicit discard one transient SQL fault writes the trail twice. - Mutation through
Addonly. The save runs underDetectChangesOncewith automatic change detection off, so anything that relies on a later detection pass to be noticed is not noticed.
PII is redacted at capture, never at read
Any property carrying [Pii] records PiiRedactor.RedactedToken
(MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:27, the class at :24) for both the
old and the new value. The trail stores the fact that a personal field changed, and never its contents.
Redacting on the way out instead would leave clear-text personal data at rest in a store that by
construction survives the erasure of the row it describes.
Opt-in is a marker interface, plus a settings section
IAuditedEntity (Domain) is an empty marker. An entity is audited because someone wrote the interface on
it, which is what keeps volume a deliberate decision rather than a framework-imposed tax.
AuditTrailSettings binds section AuditTrail (Enabled, RetentionDays default 90) through the
ADR-070 fail-fast chain, and AddAuditTrail(configuration)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:108) registers the
interceptor and the settings together, plus IAuditTrailReader (:119) and AuditTrailCleanupJob (:124).
The table lives in every relational source that adopts it
ApplicationDbContext.OnModelCreating (.../ApplicationDbContext.cs:417) calls
ConfigureAuditTrail(modelBuilder) (:436, the method itself at :850), gated on the settings flag
resolved from the root provider the way the interceptors are (:333, checked at :852), creating an
AuditTrailEntries table with two indexes: IX_AuditTrailEntries_Entity on
(EntityType, EntityKey, ChangedOn) for the read path (:870-871) and IX_AuditTrailEntries_ChangedOn
for the retention sweep (:876-877). A same-transaction write requires the table in the same database as
the data, which is the outbox precedent (ADR-006) and the reason the trail is not one central store.
Cosmos skips it, the way CosmosDbContext reports SupportsOutbox => false
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/CosmosDbContext.cs:121), and its OnModelCreating override (:124) never reaches
ConfigureAuditTrail.
Retention is the framework's first scheduled job
AuditTrailCleanupJob ships as the first IScheduledJob (ADR-074), purging rows older than
RetentionDays. The framework dogfoods its own scheduler rather than shipping a second periodic
mechanism. There is no fallback: the job runs only when the host also calls AddScheduledJobs and sets
Scheduler:Enabled, and without that the trail still records but nothing is purged, RetentionDays is
inert and pruning is the operator's job
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:97-101).
The read surface is one interface
IAuditTrailReader (Application) with an Infrastructure implementation querying by
(EntityType, EntityKey), which is exactly the index. No controller and no UI ship in v1: a change
history is a surface with real authorization and presentation opinions, and consumers hold those opinions.
Adoption in the consumer sweep is seven relational sources, each with its own migration: ADC's Identity,
Conference and Engagement services (its Notification service does not call AddAuditTrail and has no
trail migration), Store's Catalog, Sales and Identity services (Store is database-per-service, so each
adopts separately rather than sharing one database), and Helpdesk's Tickets.
Rationale
IAuditableEntityis a statement about a business row, and an audit row is not one. The interface means "this row stamps who created and last modified it and participates in soft-delete". An audit row has no author beyond the write that produced it, so the stamps would be noise; it must never be soft-deleted, because a soft-deleted audit row is a rewritten history that a query filter quietly hides; and it would recurse if the stamping interceptor touched it, since stamping is itself a change.OutboxMessagemade the same call for the same three reasons, and matching it keeps one rule for infrastructure rows rather than two.- The change tracker is the only place the diff actually exists. EF has already resolved which
properties changed and holds both values; a decorator above it would have to re-read the row to
reconstruct what EF discarded, and a database trigger would have the values but none of the identity,
correlation or
[Pii]context that makes the row useful. - Same-transaction is the difference between a trail and a hint. A trail written after the commit is lost by a crash in the window, and a trail that can be lost is one nobody can rely on in the argument it exists to settle. The outbox has run this exact mechanic in production for the whole life of the framework, so this is a proven path rather than a new one.
- Running last is what makes the captured values final. Capturing before the stamp interceptor
would record a stale
LastModifiedByon rows the same save is about to correct (the trail's ownTenantIdcomes from the context's current tenant, so it does not depend on order), which is worse than not capturing at all: a wrong history reads exactly like a right one. - Redacting at capture is the only version that survives erasure. ADR-005 anonymizes a row in place on a data-subject request. If the trail held the old clear-text value, erasure would delete the data from the row and leave it in the history, so the compliance path would be defeated by the audit path.
- Opt-in per entity keeps the cost proportional to the value. A framework that audited everything would multiply every consumer's write volume for rows nobody will ever ask about. The marker makes the cost a per-entity decision made by the team that knows which rows get argued over.
- Reusing the scheduler proves the scheduler. Retention is a real recurring job with a real failure
mode, so making it the first
IScheduledJobexercises ADR-074 in the framework's own code rather than waiting for a consumer to be the first to find out.
Trade-offs
- Write amplification is real and it is on the caller's latency path. An entity with twenty changed properties writes twenty rows inside the caller's transaction, so an audited save is slower than an unaudited one, and the trail is not a beside-the-request concern the way a background publish is.
- A per-source table makes cross-database history a fan-out. "What happened to this user across the whole system" is a query against ADC's three audited databases, and the framework does not ship that query.
- Values are strings, so a reader gets a rendering rather than a value. A decimal, an enum and a date all arrive as text formatted at capture time, and a large column is stored at whatever length it had.
- Redact-at-capture is irreversible, which is the point and also a limit. A
[Pii]field's old value is gone from the trail forever, so the trail can never answer "what was this email address before it changed". - The marker is per entity, not per property. Opting an entity in captures every changed property it
has, including the ones nobody wanted a history of; a
[Pii]property still gets a row, with the redacted token in both value columns. - Nothing enforces the interceptor ordering. It is registration order held by review, with no fitness test asserting it, and a fifth interceptor inserted in the wrong position silently changes what the trail sees rather than failing anything.
- Retention is a purge, not an archive. Rows past
RetentionDaysare deleted with no cold-storage path in v1, so the answer to a question older than the window is that there is no answer. - Cosmos-backed sources get no trail at all. The engine that cannot join the transaction is simply skipped, so a consumer's audit coverage is decided by ADR-018 placement rather than by intent.
- No shipped read surface means the capability is invisible until someone builds one. Rows accumulate
from the day
AddAuditTrailis called; nobody sees them until a consumer writes a page or an endpoint overIAuditTrailReader.
Revision (2026-09-07)
Two additions from the 2026-09-07 security review.
- The
[Pii]convention is now checked in both directions (SEC-Store-19 / SEC-Store-24). The shared base already obliged a[Pii]property's entity to be anonymizable. Store adds the reverse assertion: whateverAnonymize()actually overwrites must carry[Pii](MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Governance/PiiConventionTests.cs:76), with a companion test that every anonymizable domain entity is covered by a sample so the first test cannot pass vacuously (:55). This catches the failure mode the forward rule cannot: a field the erasure path overwrites but the attribute does not cover keeps its pre-erasure value in the audit trail'sOldValuefor the full retention window (:106), which is an erasure promise broken by the record that exists to prove erasure happened. - The trail is backstopped by database-level auditing (SEC-Store-21). The application owns its
own trail table, so an actor with write access to the database can edit the evidence. Both apps
deploy SQL auditing to the Log Analytics workspace, and both record
UPDATEandDELETEstatements against the trail table there, outside the application's reach, by different means (see the 2026-09-25 revision below). That makes the trail tamper-evident, not tamper-proof: the same credential can still rewrite the table, but a rewrite leaves a trace somewhere that credential cannot reach.
Revision (2026-09-25): both apps record trail DML, by different means
Revision item 2 above previously held for Store only: ADC's server-level audit carried no statement
auditing, so a rewrite of dbo.AuditTrailEntries by the shared admin login left no record. Both apps
now record trail DML, each shaped by its own volume decision.
- Store: statement auditing at the server.
sqlAuditingSettings(MMCA.Store/infra/main.bicep:866) listsBATCH_COMPLETED_GROUPplus the two authentication groups (:875-878), routed through aSQLSecurityAuditEventsdiagnostic setting on the master database (:852). The rationale, including why the batch group is the only way to cover the trail table at server scope, is at:822-846. The batch group records every statement, so the workspace daily cap is sized for it (MMCA.Store/infra/foundation.bicep:57,dailyQuotaGb: 3). - ADC: object-scoped auditing per database. The server-level policy
sqlServerAuditing(MMCA.ADC/infra/main.bicep:827) stays authentication, principal, role, permission and schema-change groups only (:833), with no statement group, for volume (:818-826). A database-level policy,auditTrailDmlAuditing(:984), addsUPDATE ON dbo.AuditTrailEntries BY publicandDELETE ON dbo.AuditTrailEntries BY public(:992-993) on the three databases that carry the trail (auditTrailDatabaseNames,:962), each with its ownSQLSecurityAuditEventsdiagnostic setting (auditTrailDiagnostics,:968). The rationale is at:938-961. Because the trail is append-only in normal operation, these actions fire on tampering and on the retention purge only, and the daily cap is unchanged.SqlAuditConventionTests(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Governance/SqlAuditConventionTests.cs:12) pins both actions inmain.bicep(:17) and the database list (:36).
Revision (2026-10-01): retention has no fallback, and the current-state text matches the code
- Retention runs on the scheduler or not at all. The Decision previously named a
PeriodicBackgroundServicesubclass as the fallback for a host without the scheduler. No such class exists:AddAuditTrailregistersAuditTrailCleanupJobas a scheduled job (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Jobs.cs:124), and withoutAddScheduledJobsplusScheduler:Enablednothing is purged andRetentionDaysis inert (:97-101). - Opt-in is three gates, not two.
AuditTrail:Enableddefaults to false (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:26) and gates both the table (.../ApplicationDbContext.cs:333,:852) and capture, since the interceptor returns when the model has noAuditTrailEntry(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:183).AddAuditTrailalso registersIAuditTrailReader(DependencyInjection.Jobs.cs:119). - The table carries a second index,
IX_AuditTrailEntries_ChangedOn, for the retention sweep (.../ApplicationDbContext.cs:876-877). - The ordering rationale no longer claims a null
TenantId. The trail row'sTenantIdis read from the context's current tenant (AuditTrailSaveChangesInterceptor.cs:194), not from the stamped entity, so only the staleLastModifiedByhalf depends on running last. - A
[Pii]property is captured, redacted. The Trade-offs entry previously said only non-PII properties are captured; every changed property gets a row, a personal one with the redacted token on both sides (AuditTrailSaveChangesInterceptor.cs:303). - Anchors refreshed:
ApplicationDbContext.csinterceptor block:298-323,OnModelCreating:417,ConfigureAuditTrail:436/:850;CosmosDbContext.csSupportsOutbox:121;AddAuditTrailnow cited inDependencyInjection.Jobs.cs:108. The bicep anchors in the 2026-09-25 revision have since moved (StoreMMCA.Store/infra/main.bicep:885, ADCMMCA.ADC/infra/main.bicep:846and:1003) and are left as recorded there; the auditing content is unchanged.
Related
ADR-003 (the same-transaction write this copies wholesale, including the
retry-discard and the Add-only mutation rule),
ADR-005 (soft-delete, [Pii] and erasure: why the trail redacts at
capture and why its rows are purged rather than soft-deleted),
ADR-073 (the interceptor that runs immediately before this one, and the
source of the nullable TenantId column), ADR-074 (the scheduler that
runs the retention purge, and its first consumer),
ADR-070 (the validating chain AuditTrailSettings binds
through), ADR-006 (why the table is created per relational source rather
than once), ADR-018 (the engine axis, and why a Cosmos-backed source opts
out), ADR-035 (the RowVersion token that makes a diffed update
deterministic: without it two concurrent writers can produce a history that never happened).