Skip to content

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.

WasIs now
__AuditLog (Persistence, [Audited] entities)__AuditEntries + __AuditSegments
pragmatic_config_audit (Configuration.Database)the same two tables
__MessageAudit (Messaging.Auditing)the same two tables
IConfigurationAuditStore, ConfigurationAuditEntryIAuditTrailReader, AuditEntry
IAuditStore, MessageAuditEntry, InMemoryAuditStore, EfCoreAuditStoreremoved
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.

The new entry is not a wider version of the old ones — it holds less, deliberately.

  • There is no payload field. MessageAuditEntry.PayloadJson stored 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 keeps ValueHash instead: enough to confirm a candidate, never enough to read the value back.
  • People are referenced, not named. ActorRef and SubjectRef are 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.

  1. Leave __AuditLog, pragmatic_config_audit and __MessageAudit in place. Nothing writes to them any more.
  2. Revoke write permission on them, so “nothing writes” is enforced rather than assumed.
  3. 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.

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.

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.

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.

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.
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.