Upgrading to the shared audit trail
Four modules used to keep audit records of their own. They now write to one trail,
Pragmatic.Audit. This changes the database schema and the old rows do not carry over. Read this
before upgrading a deployment that has data worth keeping.
What changed
Section titled “What changed”| Was | Is now |
|---|---|
__AuditLog (Persistence, [Audited] entities) | __AuditEntries + __AuditSegments |
pragmatic_config_audit (Configuration.Database) | the same two tables |
__MessageAudit (Messaging.Auditing) | the same two tables |
IConfigurationAuditStore, ConfigurationAuditEntry | IAuditTrailReader, AuditEntry |
IAuditStore, MessageAuditEntry, InMemoryAuditStore, EfCoreAuditStore | removed |
AuditingOptions (IncludePayload, RetentionDays) | removed — see below |
Pragmatic.Logging is not affected. Its Privacy/ folder records that a value was redacted from a
log line, which is telemetry about the logging pipeline rather than a record of what happened to
anyone’s data. The names made it look like a fourth trail; it is not one.
Why the old rows cannot be carried over
Section titled “Why the old rows cannot be carried over”The new entry is not a wider version of the old ones — it holds less, deliberately.
- There is no payload field.
MessageAuditEntry.PayloadJsonstored the serialized message, personal data included and unredacted. That single field is the reason the shared trail exists, and it is not coming back. - Values are hashed, not kept. Configuration used to store the old and new value of every key that
nobody had marked
[Sensitive]. The trail keepsValueHashinstead: enough to confirm a candidate, never enough to read the value back. - People are referenced, not named.
ActorRefandSubjectRefare pseudonyms. An old row holding a user id or an email address has no faithful translation, and inventing one would defeat the erasure design the trail was built for.
A row that cannot be translated faithfully should not be translated at all. Which leaves two honest options.
Option A — keep the old tables, read-only
Section titled “Option A — keep the old tables, read-only”The one to prefer when the history has evidential value.
- Leave
__AuditLog,pragmatic_config_auditand__MessageAuditin place. Nothing writes to them any more. - Revoke write permission on them, so “nothing writes” is enforced rather than assumed.
- Record the cut-over date somewhere your auditors will find it. A reader looking at a gap needs to know it is a migration and not a deletion.
The schema differ will not drop these tables: framework-prefixed tables are never dropped for being absent from the desired schema.
pragmatic_config_audit does not carry that prefix, but it is protected by name — along with the
configuration store’s other tables, which predate the convention.
Option B — discard them
Section titled “Option B — discard them”Reasonable when the audit was operational rather than evidential, and nobody has ever read it.
Drop the three tables after the upgrade. Say so in your own changelog: a trail that silently starts over reads, to anyone who looks later, exactly like a trail that was tampered with.
Provisioning the new tables
Section titled “Provisioning the new tables”With Pragmatic.Migrations: nothing to do. When any entity in a database is [Audited], the
generated schema now contains both tables and the runner creates them.
With EF Core migrations: call AuditDbContext.ApplyAuditConfigurations(modelBuilder) from the
context that should hold them, then add a migration.
With neither — a producer on raw ADO.NET, such as the configuration store — the dialect carries the
DDL: IAuditSqlDialect.CreateSchema.
⚠️ Both tables, always together. An entry whose segment row is missing cannot be sealed, and therefore cannot be verified: you would have a trail that can be written and never checked.
Options that no longer exist
Section titled “Options that no longer exist”AuditingOptions.IncludePayload is gone because there is no payload field.
AuditingOptions.RetentionDays is gone because retention moved to the trail, and it works differently:
AuditRetentionService discards whole sealed segments and records the gap. It never deletes
individual entries — that would change a sealed segment’s Merkle root and make retention
indistinguishable from tampering.
Two things to schedule
Section titled “Two things to schedule”Neither is optional in practice, and neither runs by itself.
AuditSealingService— until a segment is sealed it has no Merkle root and nothing to verify against.IAuditTrailReader.VerifyAsync— an integrity chain nobody checks proves nothing. This is the assumption the whole design rests on, and it is the one the code cannot enforce.
Reading the trail
Section titled “Reading the trail”var page = await reader.QueryAsync(new AuditQuery{ Category = AuditCategory.Configuration, TenantId = tenantId, Limit = 50,});GetConfigAuditLog still exists and still answers, but no longer returns the before/after values — it
returns PreviousValueHash. That is a real loss for operations, chosen knowingly.