{"id":192,"date":"2026-05-08T17:53:54","date_gmt":"2026-05-08T17:53:54","guid":{"rendered":"https:\/\/www.fabricioruch.ch\/?p=192"},"modified":"2026-05-08T17:53:54","modified_gmt":"2026-05-08T17:53:54","slug":"audit-trail-and-snapshot-diffing-in-desktop-apps","status":"publish","type":"post","link":"https:\/\/www.fabricioruch.ch\/?p=192","title":{"rendered":"Audit Trail and Snapshot Diffing in Desktop Apps"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Designing Explainable Change History with JSON Snapshots<\/h2>\n\n\n\n<p>Most applications can save data.<\/p>\n\n\n\n<p>Far fewer can explain what changed, when it changed, and why the current state looks the way it does.<\/p>\n\n\n\n<p>That difference matters.<\/p>\n\n\n\n<p>In many business applications, users eventually ask questions like:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Who changed this entry?<br>Why is this total different from yesterday?<br>Was this item deleted or only edited?<br>Which fields were changed exactly?<br>What did the object look like before the change?<\/p>\n<\/blockquote>\n\n\n\n<p>Without an audit trail, the answers depend on memory, support tickets, logs, or guesswork. With a properly designed audit trail, the answers become explicit, reproducible, and visible inside the product.<\/p>\n\n\n\n<p>Auditability is often treated as a compliance checkbox. That is too narrow. In real applications, auditability is also a product feature. It increases trust, simplifies debugging, supports accountability, and gives users confidence that the system can explain its own behavior.<\/p>\n\n\n\n<p>This article analyzes a practical audit architecture for desktop applications based on JSON snapshots, append-only audit records, snapshot diffing, and UI-oriented formatting. The focus is not only on storing historical data, but on making that history understandable for real users.<\/p>\n\n\n\n<p>The examples are based on a WPF-style desktop architecture, but the design principles apply to many business applications.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"586\" src=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1024x586.png\" alt=\"\" class=\"wp-image-193\" srcset=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1024x586.png 1024w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-300x172.png 300w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-768x439.png 768w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image.png 1386w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"540\" src=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1-1024x540.png\" alt=\"\" class=\"wp-image-194\" srcset=\"https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1-1024x540.png 1024w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1-300x158.png 300w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1-768x405.png 768w, https:\/\/www.fabricioruch.ch\/wp-content\/uploads\/2026\/05\/image-1.png 1504w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Why Logs Are Not Enough<\/h2>\n\n\n\n<p>Logs and audit trails are often confused.<\/p>\n\n\n\n<p>They are not the same thing.<\/p>\n\n\n\n<p>Logs are primarily operational. They help developers and support teams understand what happened technically: exceptions, warnings, startup events, failed operations, database errors, performance issues, and diagnostic traces.<\/p>\n\n\n\n<p>Audit trails are product-facing history. They explain meaningful business changes: a time entry was created, a project was renamed, a booking code was deactivated, or several records were deleted.<\/p>\n\n\n\n<p>A log entry may say:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>UpdateTimeEntryAsync completed successfully.\n<\/code><\/pre>\n\n\n\n<p>An audit entry should say:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Time entry changed:\nDate: 2026-05-08\nBooking code: Consulting \u2192 Internal Work\nStatus: Draft \u2192 Submitted\n<\/code><\/pre>\n\n\n\n<p>That distinction is important. Logs help operate the system. Audit trails help explain the system.<\/p>\n\n\n\n<p>A good audit trail should provide four capabilities:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>traceability: what happened<\/li>\n\n\n\n<li>accountability: who or what initiated the change<\/li>\n\n\n\n<li>diagnostics: how the current state emerged<\/li>\n\n\n\n<li>explainability: what changed in human-readable form<\/li>\n<\/ul>\n\n\n\n<p>The last point is where many implementations fail. Storing raw JSON or technical field names is not enough. Users need readable change history.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture Overview<\/h2>\n\n\n\n<p>A robust audit pipeline can be separated into four responsibilities.<\/p>\n\n\n\n<p>The write layer captures changes at the mutation boundary. This usually happens in repositories or application services that create, update, or delete entities.<\/p>\n\n\n\n<p>The persistence layer stores append-only audit records. Each audit record contains metadata and snapshots of entity state before and after the change.<\/p>\n\n\n\n<p>The interpretation layer turns raw snapshots into readable change details. It parses JSON, flattens object structures, compares old and new values, classifies changes, and maps technical property names to user-facing labels.<\/p>\n\n\n\n<p>The presentation layer displays the result in the UI. Users see concise history entries, expandable details, and readable before\/after values.<\/p>\n\n\n\n<p>This separation is the core of the design.<\/p>\n\n\n\n<p>Storage format should not dictate UI wording. UI wording should not distort raw historical truth. Diff logic should not be duplicated across screens. Snapshot serialization should not be mixed into view models.<\/p>\n\n\n\n<p>A clean audit architecture keeps these concerns separate.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Audit Record Model<\/h2>\n\n\n\n<p>A practical audit record usually contains the following data:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>timestamp in UTC<\/li>\n\n\n\n<li>actor or user identifier<\/li>\n\n\n\n<li>operation type<\/li>\n\n\n\n<li>entity type<\/li>\n\n\n\n<li>entity id<\/li>\n\n\n\n<li>old snapshot JSON<\/li>\n\n\n\n<li>new snapshot JSON<\/li>\n\n\n\n<li>optional context<\/li>\n<\/ul>\n\n\n\n<p>The operation type usually includes values such as:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>created<\/li>\n\n\n\n<li>updated<\/li>\n\n\n\n<li>deleted<\/li>\n\n\n\n<li>bulk deleted<\/li>\n\n\n\n<li>imported<\/li>\n\n\n\n<li>approved<\/li>\n\n\n\n<li>rejected<\/li>\n<\/ul>\n\n\n\n<p>The old snapshot is nullable for create operations because no previous state exists. The new snapshot is nullable for delete operations because the entity no longer exists after deletion.<\/p>\n\n\n\n<p>Conceptually, an audit entry looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public sealed class AuditEntry\n{\n    public int Id { get; set; }\n\n    public DateTime TimestampUtc { get; set; }\n\n    public string? Actor { get; set; }\n\n    public string Entity { get; set; } = string.Empty;\n\n    public string EntityId { get; set; } = string.Empty;\n\n    public ChangeType ChangeType { get; set; }\n\n    public string? OldValue { get; set; }\n\n    public string? NewValue { get; set; }\n\n    public string? Context { get; set; }\n}\n<\/code><\/pre>\n\n\n\n<p>The important architectural decision is that audit entries are append-only. Existing records should not be modified as part of normal application behavior.<\/p>\n\n\n\n<p>That gives the audit trail credibility. It becomes a historical ledger rather than a secondary projection of current state.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Why JSON Snapshots Work Well<\/h2>\n\n\n\n<p>JSON snapshots are a pragmatic choice for desktop business applications.<\/p>\n\n\n\n<p>They are flexible, easy to persist as text, easy to inspect during debugging, and easy to compare. They also avoid the need for one rigid audit table per entity type.<\/p>\n\n\n\n<p>Instead of storing only changed fields, the system stores the relevant state of an entity before and after the mutation.<\/p>\n\n\n\n<p>For example, a time entry snapshot may contain:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"id\": 42,\n  \"date\": \"2026-05-08\",\n  \"start\": \"08:00\",\n  \"end\": \"12:00\",\n  \"bookingCodeId\": 3,\n  \"bookingCodeLabel\": \"Consulting\",\n  \"projectId\": 7,\n  \"projectName\": \"Customer Portal\",\n  \"freeText\": \"Architecture review\",\n  \"status\": \"Draft\",\n  \"rejectionComment\": null,\n  \"createdAt\": \"2026-05-08T06:00:00Z\",\n  \"updatedAt\": \"2026-05-08T10:15:00Z\"\n}\n<\/code><\/pre>\n\n\n\n<p>This snapshot is not the database schema. It is an audit representation.<\/p>\n\n\n\n<p>That distinction matters.<\/p>\n\n\n\n<p>The audit snapshot should contain data that helps explain the historical state. Including both IDs and human-readable reference labels is often valuable. For example, storing <code>bookingCodeId<\/code> alone is technically correct, but storing <code>bookingCodeLabel<\/code> as well makes the audit trail much more useful later.<\/p>\n\n\n\n<p>If a booking code is renamed in the future, the old audit record can still explain what the user saw at the time of change.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Snapshot Serialization as a Separate Responsibility<\/h2>\n\n\n\n<p>A strong part of this architecture is that snapshot serialization is centralized.<\/p>\n\n\n\n<p>The serializer creates a stable JSON representation of an entity. It formats dates, times, references, status values, and optional labels consistently.<\/p>\n\n\n\n<p>A representative example is a time entry snapshot:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public static string? TimeEntrySnapshot(\n    TimeEntry? entry,\n    string? bookingCodeLabel = null,\n    string? projectName = null)\n{\n    if (entry == null)\n    {\n        return null;\n    }\n\n    var dto = new\n    {\n        entry.Id,\n        date = SqliteFormat.SqlDate(entry.Date),\n        start = entry.Start.ToString(@\"hh\\:mm\"),\n        end = entry.End.ToString(@\"hh\\:mm\"),\n        entry.BookingCodeId,\n        bookingCodeLabel,\n        projectId = entry.ProjectId,\n        projectName,\n        freeText = entry.FreeText,\n        status = entry.Status.ToString(),\n        rejectionComment = entry.RejectionComment,\n        createdAt = SqliteFormat.SqlRoundTripUtc(entry.CreatedAt),\n        updatedAt = SqliteFormat.SqlRoundTripUtc(entry.UpdatedAt)\n    };\n\n    return JsonSerializer.Serialize(dto, JsonOptions);\n}\n<\/code><\/pre>\n\n\n\n<p>This design is strong for several reasons.<\/p>\n\n\n\n<p>First, time and date values are normalized. That is essential for meaningful comparison. If one snapshot stores a date as <code>08.05.2026<\/code> and another stores it as <code>2026-05-08<\/code>, the diff engine may report a change even when the value is semantically identical.<\/p>\n\n\n\n<p>Second, the snapshot is independent of the UI model. The audit representation can remain stable even if the WPF view model changes.<\/p>\n\n\n\n<p>Third, reference information is enriched. Fields such as <code>bookingCodeLabel<\/code> and <code>projectName<\/code> help the UI display meaningful history without resolving every old reference dynamically.<\/p>\n\n\n\n<p>Fourth, serialization becomes testable. The application can verify that important entities produce predictable audit snapshots.<\/p>\n\n\n\n<p>This is not just a helper method. It is part of the audit contract.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Repository Integration: Capturing Change at the Source<\/h2>\n\n\n\n<p>The most reliable audit systems capture history where mutations happen.<\/p>\n\n\n\n<p>For an update operation, the typical flow is:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Load the current persisted entity.<\/li>\n\n\n\n<li>Serialize it as the old snapshot.<\/li>\n\n\n\n<li>Apply the mutation.<\/li>\n\n\n\n<li>Persist the new state.<\/li>\n\n\n\n<li>Serialize the resulting entity as the new snapshot.<\/li>\n\n\n\n<li>Append an audit record.<\/li>\n<\/ol>\n\n\n\n<p>The mutation boundary is the right place to capture audit data because this is where the application still has access to both the previous and new state.<\/p>\n\n\n\n<p>A simplified update flow may look like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public async Task UpdateTimeEntryAsync(\n    TimeEntry updatedEntry,\n    CancellationToken ct = default)\n{\n    var existingEntry = await _timeEntryRepository.GetByIdAsync(\n        updatedEntry.Id,\n        ct);\n\n    var oldSnapshot = AuditSnapshotSerializer.TimeEntrySnapshot(\n        existingEntry,\n        existingEntry.BookingCodeLabel,\n        existingEntry.ProjectName);\n\n    await _timeEntryRepository.UpdateAsync(updatedEntry, ct);\n\n    var newSnapshot = AuditSnapshotSerializer.TimeEntrySnapshot(\n        updatedEntry,\n        updatedEntry.BookingCodeLabel,\n        updatedEntry.ProjectName);\n\n    await _auditRepository.AppendAsync(\n        new AuditEntry\n        {\n            TimestampUtc = DateTime.UtcNow,\n            Entity = AuditEntityNames.TimeEntry,\n            EntityId = updatedEntry.Id.ToString(),\n            ChangeType = ChangeType.Updated,\n            OldValue = oldSnapshot,\n            NewValue = newSnapshot\n        },\n        ct);\n}\n<\/code><\/pre>\n\n\n\n<p>The exact location depends on the application architecture. In some systems, this belongs in repositories. In others, it belongs in application services that orchestrate repositories.<\/p>\n\n\n\n<p>The important principle is that auditing should happen close to the mutation, not as a later UI-side interpretation.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Best-Effort Audit Writes vs Strict Audit Guarantees<\/h2>\n\n\n\n<p>There is an important design decision: should the main operation fail if the audit write fails?<\/p>\n\n\n\n<p>There are two valid approaches.<\/p>\n\n\n\n<p>A strict transactional audit means the business operation and audit record succeed or fail together. This gives stronger historical guarantees, but increases coupling. If the audit table cannot be written, the user operation fails.<\/p>\n\n\n\n<p>A best-effort audit means the application attempts to write audit data, but does not block the main operation if audit writing fails. This improves availability, but introduces possible audit gaps.<\/p>\n\n\n\n<p>Neither choice is universally correct.<\/p>\n\n\n\n<p>For compliance-heavy systems, strict transactional audit may be necessary. For many desktop productivity tools, best-effort auditing may be acceptable if failures are logged clearly.<\/p>\n\n\n\n<p>The critical point is to document the decision. An audit trail that silently drops records without operational visibility creates false trust.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">From Raw Snapshots to Human-Readable Diffs<\/h2>\n\n\n\n<p>Raw JSON is useful for storage and debugging, but it is not enough for users.<\/p>\n\n\n\n<p>A readable audit trail needs an interpretation pipeline.<\/p>\n\n\n\n<p>The pipeline usually works like this:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Parse old and new JSON snapshots.<\/li>\n\n\n\n<li>Flatten nested JSON structures into key-value paths.<\/li>\n\n\n\n<li>Normalize primitive values into display strings.<\/li>\n\n\n\n<li>Compare old and new maps.<\/li>\n\n\n\n<li>Classify each change.<\/li>\n\n\n\n<li>Map technical field names to user-facing labels.<\/li>\n\n\n\n<li>Return UI-ready rows.<\/li>\n<\/ol>\n\n\n\n<p>The output should not be raw JSON. It should be a structured list of change rows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public sealed class AuditDiffRow\n{\n    public string PropertyPath { get; set; } = string.Empty;\n\n    public string DisplayName { get; set; } = string.Empty;\n\n    public string OldDisplay { get; set; } = string.Empty;\n\n    public string NewDisplay { get; set; } = string.Empty;\n\n    public AuditDiffKind Kind { get; set; }\n}\n<\/code><\/pre>\n\n\n\n<p>The change kind can be modeled explicitly:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public enum AuditDiffKind\n{\n    Added,\n    Removed,\n    Modified,\n    Unchanged,\n    Unreadable\n}\n<\/code><\/pre>\n\n\n\n<p>This is a clean UI contract. The UI does not need to understand JSON parsing. It can simply display rows.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Diff Builder as the Interpretation Boundary<\/h2>\n\n\n\n<p>The <code>AuditSnapshotDiffBuilder<\/code> is the central interpretation component.<\/p>\n\n\n\n<p>It accepts an <code>AuditEntry<\/code> and returns UI-ready <code>AuditDiffRow<\/code> objects.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public static class AuditSnapshotDiffBuilder\n{\n    private const string EmDash = \"\\u2014\";\n    private const int RawPreviewMax = 800;\n\n    public static IReadOnlyList&lt;AuditDiffRow&gt; Build(AuditEntry entry)\n    {\n        try\n        {\n            return BuildCore(entry);\n        }\n        catch (JsonException)\n        {\n            return\n            &#91;\n                new AuditDiffRow\n                {\n                    PropertyPath = \"_parseError\",\n                    DisplayName = \"Daten\",\n                    OldDisplay = StringUtils.TruncateForDisplay(\n                        entry.OldValue,\n                        RawPreviewMax,\n                        EmDash),\n                    NewDisplay = StringUtils.TruncateForDisplay(\n                        entry.NewValue,\n                        RawPreviewMax,\n                        EmDash),\n                    Kind = AuditDiffKind.Unreadable\n                }\n            ];\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>This design is important because it treats malformed or legacy audit data as expected reality.<\/p>\n\n\n\n<p>Historical data may not always match the current schema. JSON may be malformed. Older snapshots may contain fields that no longer exist. A previous version may have serialized values differently.<\/p>\n\n\n\n<p>The UI must not crash because of this.<\/p>\n\n\n\n<p>The fallback to an <code>Unreadable<\/code> row is a strong architectural decision. It preserves usability and still gives support teams enough raw context to investigate.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Create, Update, and Delete Differently<\/h2>\n\n\n\n<p>Create, update, and delete operations have different snapshot semantics.<\/p>\n\n\n\n<p>For a created entity, there is no old value. The diff builder reads the new snapshot and reports fields as added.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static IReadOnlyList&lt;AuditDiffRow&gt; BuildCreated(AuditEntry entry)\n{\n    var dict = JsonFlatMap.ParseFlatObject(entry.NewValue);\n\n    if (dict is null)\n    {\n        return BuildUnreadable(\n            \"Nach dem Anlegen liegen keine lesbaren Daten vor.\",\n            null,\n            entry.NewValue);\n    }\n\n    if (dict.Count == 0)\n    {\n        return\n        &#91;\n            new AuditDiffRow\n            {\n                PropertyPath = \"_empty\",\n                DisplayName = \"Daten\",\n                OldDisplay = EmDash,\n                NewDisplay = EmDash,\n                Kind = AuditDiffKind.Added\n            }\n        ];\n    }\n\n    var rows = dict.Select(kv =&gt; new AuditDiffRow\n    {\n        PropertyPath = kv.Key,\n        DisplayName = AuditFieldDisplayNames.ForProperty(entry.Entity, kv.Key),\n        OldDisplay = EmDash,\n        NewDisplay = kv.Value,\n        Kind = AuditDiffKind.Added\n    }).ToList();\n\n    SortRows(rows);\n\n    return rows;\n}\n<\/code><\/pre>\n\n\n\n<p>For a deleted entity, there is no new value. The diff builder reads the old snapshot and reports fields as removed.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static IReadOnlyList&lt;AuditDiffRow&gt; BuildDeleted(AuditEntry entry)\n{\n    var dict = JsonFlatMap.ParseFlatObject(entry.OldValue);\n\n    if (dict is null)\n    {\n        return BuildUnreadable(\n            \"Vor dem L\u00f6schen liegen keine lesbaren Daten vor.\",\n            entry.OldValue,\n            null);\n    }\n\n    if (dict.Count == 0)\n    {\n        return\n        &#91;\n            new AuditDiffRow\n            {\n                PropertyPath = \"_empty\",\n                DisplayName = \"Daten\",\n                OldDisplay = EmDash,\n                NewDisplay = EmDash,\n                Kind = AuditDiffKind.Removed\n            }\n        ];\n    }\n\n    var rows = dict.Select(kv =&gt; new AuditDiffRow\n    {\n        PropertyPath = kv.Key,\n        DisplayName = AuditFieldDisplayNames.ForProperty(entry.Entity, kv.Key),\n        OldDisplay = kv.Value,\n        NewDisplay = EmDash,\n        Kind = AuditDiffKind.Removed\n    }).ToList();\n\n    SortRows(rows);\n\n    return rows;\n}\n<\/code><\/pre>\n\n\n\n<p>For an update, both snapshots are parsed and compared.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static IReadOnlyList&lt;AuditDiffRow&gt; BuildUpdated(AuditEntry entry)\n{\n    var oldDict =\n        JsonFlatMap.ParseFlatObject(entry.OldValue)\n        ?? new Dictionary&lt;string, string&gt;(StringComparer.Ordinal);\n\n    var newDict =\n        JsonFlatMap.ParseFlatObject(entry.NewValue)\n        ?? new Dictionary&lt;string, string&gt;(StringComparer.Ordinal);\n\n    if (oldDict.Count == 0 &amp;&amp; newDict.Count == 0)\n    {\n        return\n        &#91;\n            new AuditDiffRow\n            {\n                PropertyPath = \"_empty\",\n                DisplayName = \"Daten\",\n                OldDisplay = EmDash,\n                NewDisplay = EmDash,\n                Kind = AuditDiffKind.Unchanged\n            }\n        ];\n    }\n\n    var keys = oldDict.Keys\n        .Union(newDict.Keys, StringComparer.Ordinal)\n        .ToList();\n\n    var rows = new List&lt;AuditDiffRow&gt;(keys.Count);\n\n    foreach (var key in keys)\n    {\n        var oldHas = oldDict.TryGetValue(key, out var oldVal);\n        var newHas = newDict.TryGetValue(key, out var newVal);\n\n        var kind = ClassifyUpdate(oldHas, newHas, oldVal, newVal);\n\n        var oldDisp =\n            !oldHas ? EmDash :\n            string.IsNullOrEmpty(oldVal) ? EmDash :\n            oldVal!;\n\n        var newDisp =\n            !newHas ? EmDash :\n            string.IsNullOrEmpty(newVal) ? EmDash :\n            newVal!;\n\n        rows.Add(new AuditDiffRow\n        {\n            PropertyPath = key,\n            DisplayName = AuditFieldDisplayNames.ForProperty(entry.Entity, key),\n            OldDisplay = oldDisp,\n            NewDisplay = newDisp,\n            Kind = kind\n        });\n    }\n\n    SortRows(rows);\n\n    return rows;\n}\n<\/code><\/pre>\n\n\n\n<p>The key point is that the builder does not treat all changes generically. It respects the semantics of the operation.<\/p>\n\n\n\n<p>Creation means fields appeared. Deletion means fields disappeared. Update means values must be compared.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Deterministic Change Classification<\/h2>\n\n\n\n<p>The classification logic is deliberately simple.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static AuditDiffKind ClassifyUpdate(\n    bool oldHas,\n    bool newHas,\n    string? oldVal,\n    string? newVal)\n{\n    if (!oldHas &amp;&amp; newHas)\n    {\n        return AuditDiffKind.Added;\n    }\n\n    if (oldHas &amp;&amp; !newHas)\n    {\n        return AuditDiffKind.Removed;\n    }\n\n    var o = oldVal ?? string.Empty;\n    var n = newVal ?? string.Empty;\n\n    return o == n\n        ? AuditDiffKind.Unchanged\n        : AuditDiffKind.Modified;\n}\n<\/code><\/pre>\n\n\n\n<p>This is good design.<\/p>\n\n\n\n<p>The logic is transparent. It has no hidden heuristics. It is easy to test. It behaves deterministically.<\/p>\n\n\n\n<p>That matters because audit systems must be trustworthy. If a user sees a change marked as modified, the reason should be explainable.<\/p>\n\n\n\n<p>The trade-off is that this comparison is string-based. If formatting changes between versions, the diff may report a modification even when the semantic value did not change. For example, <code>8:00<\/code> and <code>08:00<\/code> may represent the same time but appear different as strings.<\/p>\n\n\n\n<p>That does not make the design wrong. It means snapshot formatting must be stable, and schema evolution must be handled carefully.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Display Names: Turning Technical Fields into User Language<\/h2>\n\n\n\n<p>A raw property path such as <code>bookingCodeId<\/code> is meaningful to developers, but not ideal for users.<\/p>\n\n\n\n<p>The <code>AuditFieldDisplayNames<\/code> component maps technical JSON fields to user-facing labels.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public static class AuditFieldDisplayNames\n{\n    private static readonly Dictionary&lt;(string Entity, string Key), string&gt; Labels = new()\n    {\n        &#91;(AuditEntityNames.TimeEntry, \"id\")] = \"Id\",\n        &#91;(AuditEntityNames.TimeEntry, \"date\")] = \"Datum\",\n        &#91;(AuditEntityNames.TimeEntry, \"start\")] = \"Beginn\",\n        &#91;(AuditEntityNames.TimeEntry, \"end\")] = \"Ende\",\n        &#91;(AuditEntityNames.TimeEntry, \"bookingCodeId\")] = \"Buchungssatz (Id)\",\n        &#91;(AuditEntityNames.TimeEntry, \"bookingCodeLabel\")] = \"Buchungssatz\",\n        &#91;(AuditEntityNames.TimeEntry, \"projectId\")] = \"Projekt (Id)\",\n        &#91;(AuditEntityNames.TimeEntry, \"projectName\")] = \"Projekt\",\n        &#91;(AuditEntityNames.TimeEntry, \"freeText\")] = \"Freitext\",\n        &#91;(AuditEntityNames.TimeEntry, \"status\")] = \"Status\",\n        &#91;(AuditEntityNames.TimeEntry, \"rejectionComment\")] = \"Ablehnungskommentar\",\n        &#91;(AuditEntityNames.TimeEntry, \"createdAt\")] = \"Angelegt am\",\n        &#91;(AuditEntityNames.TimeEntry, \"updatedAt\")] = \"Ge\u00e4ndert am\",\n\n        &#91;(AuditEntityNames.TimeEntriesBulk, \"deletedCount\")] = \"Gel\u00f6schte Zeiteintr\u00e4ge\",\n\n        &#91;(AuditEntityNames.Project, \"id\")] = \"Id\",\n        &#91;(AuditEntityNames.Project, \"name\")] = \"Name\",\n        &#91;(AuditEntityNames.Project, \"isActive\")] = \"Aktiv\",\n        &#91;(AuditEntityNames.Project, \"createdAt\")] = \"Angelegt am\",\n        &#91;(AuditEntityNames.Project, \"updatedAt\")] = \"Ge\u00e4ndert am\",\n\n        &#91;(AuditEntityNames.BookingCode, \"id\")] = \"Id\",\n        &#91;(AuditEntityNames.BookingCode, \"code\")] = \"Code\",\n        &#91;(AuditEntityNames.BookingCode, \"title\")] = \"Titel\",\n        &#91;(AuditEntityNames.BookingCode, \"freeTextAllowed\")] = \"Freitext erlaubt\",\n        &#91;(AuditEntityNames.BookingCode, \"isActive\")] = \"Aktiv\",\n        &#91;(AuditEntityNames.BookingCode, \"createdAt\")] = \"Angelegt am\",\n        &#91;(AuditEntityNames.BookingCode, \"updatedAt\")] = \"Ge\u00e4ndert am\",\n    };\n\n    public static string ForProperty(string? entity, string propertyPath)\n    {\n        var leaf = StringUtils.GetDottedPathLeaf(propertyPath);\n\n        if (entity is not null &amp;&amp; Labels.TryGetValue((entity, leaf), out var label))\n        {\n            return label;\n        }\n\n        return StringUtils.HumanizeIdentifier(leaf);\n    }\n\n    public static string EntityTypeLabel(string entity)\n    {\n        return entity switch\n        {\n            AuditEntityNames.TimeEntry =&gt; \"Zeiteintrag\",\n            AuditEntityNames.TimeEntriesBulk =&gt; \"Zeiteintr\u00e4ge (Massenl\u00f6schung)\",\n            AuditEntityNames.Project =&gt; \"Projekt\",\n            AuditEntityNames.BookingCode =&gt; \"Buchungscode\",\n            _ =&gt; entity\n        };\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>This component is small, but architecturally important.<\/p>\n\n\n\n<p>It separates technical snapshot structure from UI language. The diff builder can operate generically on property paths, while the display-name mapper gives users readable labels.<\/p>\n\n\n\n<p>The fallback is also important:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>return StringUtils.HumanizeIdentifier(leaf);\n<\/code><\/pre>\n\n\n\n<p>Unknown fields do not break the UI. They are still displayed in a tolerable form.<\/p>\n\n\n\n<p>That is exactly the kind of defensive behavior audit systems need.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Reference Summaries: Making Audit Lists Useful<\/h2>\n\n\n\n<p>Audit screens usually need two levels of information.<\/p>\n\n\n\n<p>The detail view shows exact field changes. The list view needs short summaries so users can quickly scan history.<\/p>\n\n\n\n<p>For example, instead of showing:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>TimeEntry #42\n<\/code><\/pre>\n\n\n\n<p>the UI should show:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Consulting \u00b7 Customer Portal\n<\/code><\/pre>\n\n\n\n<p>The <code>AuditReferenceSummaryFormatter<\/code> provides this bridge.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public static class AuditReferenceSummaryFormatter\n{\n    public static string Format(AuditEntry entry)\n    {\n        return entry.Entity switch\n        {\n            AuditEntityNames.TimeEntry =&gt; FormatTimeEntry(entry),\n            AuditEntityNames.TimeEntriesBulk =&gt; FormatBulk(entry),\n            AuditEntityNames.Project =&gt; FormatProject(entry),\n            AuditEntityNames.BookingCode =&gt; FormatBookingCode(entry),\n            _ =&gt; string.Empty\n        };\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>For time entries, it reads the most relevant snapshot and tries to extract booking code and project information.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static string FormatTimeEntry(AuditEntry entry)\n{\n    var json = PickSnapshotJson(entry);\n\n    if (string.IsNullOrWhiteSpace(json))\n    {\n        return string.Empty;\n    }\n\n    try\n    {\n        using var doc = JsonDocument.Parse(json);\n        var root = doc.RootElement;\n\n        var booking = JsonSnapshotText.TryGetPropertyDisplayValue(\n            root,\n            \"bookingCodeLabel\");\n\n        if (string.IsNullOrEmpty(booking)\n            &amp;&amp; root.TryGetProperty(\"bookingCodeId\", out var bcId)\n            &amp;&amp; bcId.ValueKind == JsonValueKind.Number)\n        {\n            booking = $\"Buchungssatz #{bcId.GetInt32()}\";\n        }\n\n        var project = JsonSnapshotText.TryGetPropertyDisplayValue(\n            root,\n            \"projectName\");\n\n        if (string.IsNullOrEmpty(project)\n            &amp;&amp; root.TryGetProperty(\"projectId\", out var pId)\n            &amp;&amp; pId.ValueKind == JsonValueKind.Number)\n        {\n            var id = pId.GetInt32();\n            project = id == 0 ? null : $\"Projekt #{id}\";\n        }\n\n        if (string.IsNullOrEmpty(booking) &amp;&amp; string.IsNullOrEmpty(project))\n        {\n            return string.Empty;\n        }\n\n        if (string.IsNullOrEmpty(project))\n        {\n            return booking ?? string.Empty;\n        }\n\n        if (string.IsNullOrEmpty(booking))\n        {\n            return project;\n        }\n\n        return $\"{booking} \u00b7 {project}\";\n    }\n    catch\n    {\n        return string.Empty;\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>This is a good example of UI-oriented resilience.<\/p>\n\n\n\n<p>The formatter prefers readable labels. If labels are missing, it falls back to IDs. If parsing fails, it returns an empty string instead of crashing the audit view.<\/p>\n\n\n\n<p>That is the right behavior for list summaries. A broken summary should not make the entire audit screen unusable.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Choosing the Right Snapshot for Summaries<\/h2>\n\n\n\n<p>The formatter includes a subtle but important method:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static string? PickSnapshotJson(AuditEntry entry)\n{\n    return entry.ChangeType switch\n    {\n        ChangeType.Deleted =&gt; entry.OldValue,\n        _ =&gt; entry.NewValue ?? entry.OldValue\n    };\n}\n<\/code><\/pre>\n\n\n\n<p>For deleted entities, the old snapshot is the meaningful one because the new state no longer exists.<\/p>\n\n\n\n<p>For created and updated entities, the new snapshot usually represents the best current label.<\/p>\n\n\n\n<p>This small method encodes operation semantics cleanly. Without it, delete audit rows often lose useful context.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Bulk Operation Summaries<\/h2>\n\n\n\n<p>Bulk changes need special handling.<\/p>\n\n\n\n<p>A mass deletion of time entries should not display raw JSON. It should summarize the impact.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static string FormatBulk(AuditEntry entry)\n{\n    var json = entry.OldValue ?? entry.NewValue;\n\n    if (string.IsNullOrWhiteSpace(json))\n    {\n        return string.Empty;\n    }\n\n    try\n    {\n        using var doc = JsonDocument.Parse(json);\n\n        var deleted = JsonSnapshotText.TryGetPropertyDisplayValue(\n            doc.RootElement,\n            \"deletedCount\");\n\n        if (deleted is not null &amp;&amp; int.TryParse(deleted, out var n))\n        {\n            return $\"{n} Zeiteintr\u00e4ge\";\n        }\n    }\n    catch\n    {\n        \/\/ ignored\n    }\n\n    return string.Empty;\n}\n<\/code><\/pre>\n\n\n\n<p>This is pragmatic and useful.<\/p>\n\n\n\n<p>Bulk operations often cannot reasonably show every changed entity in a single table row. A summary such as <code>17 time entries<\/code> gives the user immediate context while still allowing deeper details elsewhere if needed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Sorting Diff Rows for Readability<\/h2>\n\n\n\n<p>The diff builder also sorts rows before returning them.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private static void SortRows(List&lt;AuditDiffRow&gt; rows)\n{\n    rows.Sort(static (a, b) =&gt;\n    {\n        static int IdRank(AuditDiffRow r)\n        {\n            if (r.PropertyPath.Equals(\"id\", StringComparison.Ordinal))\n            {\n                return 0;\n            }\n\n            return r.PropertyPath.EndsWith(\".id\", StringComparison.Ordinal)\n                ? 0\n                : 1;\n        }\n\n        var ra = IdRank(a);\n        var rb = IdRank(b);\n\n        if (ra != rb)\n        {\n            return ra.CompareTo(rb);\n        }\n\n        return string.Compare(\n            a.DisplayName,\n            b.DisplayName,\n            StringComparison.CurrentCultureIgnoreCase);\n    });\n}\n<\/code><\/pre>\n\n\n\n<p>This is another small but valuable UX decision.<\/p>\n\n\n\n<p>IDs are placed first. Remaining fields are sorted by display name. The result is more predictable than returning dictionary order.<\/p>\n\n\n\n<p>Predictable ordering matters in audit UIs because users compare changes visually. Random or unstable ordering makes the history harder to scan.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">UI Design for Audit Screens<\/h2>\n\n\n\n<p>An audit screen should not be a technical dump.<\/p>\n\n\n\n<p>A good audit UI supports investigation.<\/p>\n\n\n\n<p>The list view should show:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>timestamp<\/li>\n\n\n\n<li>action<\/li>\n\n\n\n<li>entity type<\/li>\n\n\n\n<li>actor<\/li>\n\n\n\n<li>short entity summary<\/li>\n<\/ul>\n\n\n\n<p>The detail view should show:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>field label<\/li>\n\n\n\n<li>old value<\/li>\n\n\n\n<li>new value<\/li>\n\n\n\n<li>change type<\/li>\n<\/ul>\n\n\n\n<p>A useful layout is:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;Audit List]\n2026-05-08 10:15 | Updated | Time Entry | Consulting \u00b7 Customer Portal\n\n&#91;Details]\nField              Before              After\nStatus             Draft               Submitted\nUpdated at          10:00               10:15\nProject             Internal            Customer Portal\n<\/code><\/pre>\n\n\n\n<p>For malformed or legacy snapshots, the UI should show a controlled fallback:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>The snapshot could not be parsed.\nRaw previous value: ...\nRaw new value: ...\n<\/code><\/pre>\n\n\n\n<p>That is better than hiding the entry or crashing the screen.<\/p>\n\n\n\n<p>The goal is not to expose every technical detail by default. The goal is to provide a readable narrative with enough precision to support trust and troubleshooting.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Defensive Handling and Schema Evolution<\/h2>\n\n\n\n<p>Audit systems must assume imperfect historical data.<\/p>\n\n\n\n<p>Common failure modes include:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>old snapshot format differs from current expectations<\/li>\n\n\n\n<li>fields are missing<\/li>\n\n\n\n<li>fields were renamed<\/li>\n\n\n\n<li>enum values changed<\/li>\n\n\n\n<li>serializer settings changed<\/li>\n\n\n\n<li>references no longer exist<\/li>\n\n\n\n<li>old JSON is malformed<\/li>\n\n\n\n<li>audit writes were partially successful<\/li>\n<\/ul>\n\n\n\n<p>The current design handles several of these risks well.<\/p>\n\n\n\n<p>The diff builder catches <code>JsonException<\/code> and returns an unreadable row instead of crashing. The display-name mapper falls back to humanized identifiers. The reference formatter catches parse errors and returns empty summaries. The diff logic tolerates missing fields by treating them as added or removed.<\/p>\n\n\n\n<p>For long-lived applications, one additional improvement is worth considering: snapshot versioning.<\/p>\n\n\n\n<p>Example:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"schemaVersion\": 1,\n  \"id\": 42,\n  \"date\": \"2026-05-08\",\n  \"start\": \"08:00\",\n  \"end\": \"12:00\"\n}\n<\/code><\/pre>\n\n\n\n<p>A <code>schemaVersion<\/code> field makes future migrations and compatibility handling easier. The diff builder can then apply version-aware interpretation when needed.<\/p>\n\n\n\n<p>This is especially useful when audit history must remain readable for years.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Performance Considerations<\/h2>\n\n\n\n<p>Audit history can grow quickly.<\/p>\n\n\n\n<p>A desktop app may run for years on the same local database. If every create, update, delete, import, and bulk operation writes audit entries, the audit table can become large.<\/p>\n\n\n\n<p>The main performance risks are:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>loading too many audit records at once<\/li>\n\n\n\n<li>parsing large JSON payloads on the UI thread<\/li>\n\n\n\n<li>recomputing diffs repeatedly<\/li>\n\n\n\n<li>rendering too many expanded details<\/li>\n\n\n\n<li>storing unnecessarily large snapshots<\/li>\n<\/ul>\n\n\n\n<p>Practical optimizations include:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>paginate audit history<\/li>\n\n\n\n<li>load recent entries first<\/li>\n\n\n\n<li>compute detailed diffs only when an item is expanded<\/li>\n\n\n\n<li>cache parsed snapshots for expanded rows<\/li>\n\n\n\n<li>keep list summaries lightweight<\/li>\n\n\n\n<li>move heavy diff computation off the UI thread<\/li>\n\n\n\n<li>limit raw fallback preview length<\/li>\n<\/ul>\n\n\n\n<p>The existing <code>RawPreviewMax<\/code> constant is a good example of this thinking:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>private const int RawPreviewMax = 800;\n<\/code><\/pre>\n\n\n\n<p>It prevents unreadable fallback rows from flooding the UI with massive raw payloads.<\/p>\n\n\n\n<p>That kind of defensive limit is important in desktop applications because the UI thread must remain responsive.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Strategy<\/h2>\n\n\n\n<p>Audit code is highly testable when designed correctly.<\/p>\n\n\n\n<p>The best tests should target deterministic components.<\/p>\n\n\n\n<p>Snapshot serializer tests should verify that known entities produce stable JSON. These tests protect against accidental formatting changes that would create noisy diffs.<\/p>\n\n\n\n<p>Flattening tests should verify nested JSON behavior. For example, nested objects should become dotted paths consistently.<\/p>\n\n\n\n<p>Diff builder tests should cover:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>created entity<\/li>\n\n\n\n<li>deleted entity<\/li>\n\n\n\n<li>updated entity<\/li>\n\n\n\n<li>added field<\/li>\n\n\n\n<li>removed field<\/li>\n\n\n\n<li>modified field<\/li>\n\n\n\n<li>unchanged field<\/li>\n\n\n\n<li>empty snapshot<\/li>\n\n\n\n<li>malformed JSON<\/li>\n\n\n\n<li>unknown change type<\/li>\n<\/ul>\n\n\n\n<p>Display-name tests should verify both known mappings and fallback behavior.<\/p>\n\n\n\n<p>Reference summary tests should verify:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>booking code label extraction<\/li>\n\n\n\n<li>project name extraction<\/li>\n\n\n\n<li>fallback to IDs<\/li>\n\n\n\n<li>deleted entity snapshot selection<\/li>\n\n\n\n<li>malformed JSON tolerance<\/li>\n\n\n\n<li>bulk deletion summaries<\/li>\n<\/ul>\n\n\n\n<p>The strongest addition is an integration test that verifies the full flow:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>mutate entity<\/li>\n\n\n\n<li>capture old snapshot<\/li>\n\n\n\n<li>persist new state<\/li>\n\n\n\n<li>append audit entry<\/li>\n\n\n\n<li>read audit entry<\/li>\n\n\n\n<li>build diff rows<\/li>\n\n\n\n<li>verify UI-ready output<\/li>\n<\/ol>\n\n\n\n<p>That kind of test catches drift between layers.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Code-Level Strengths of This Design<\/h2>\n\n\n\n<p>The architecture has several strong qualities.<\/p>\n\n\n\n<p>The first strength is serializer separation. Snapshot generation is not mixed into the UI or diff builder. That makes the audit representation explicit and easier to evolve.<\/p>\n\n\n\n<p>The second strength is centralized diff logic. One component owns the transformation from audit entry to UI rows. This prevents duplicated comparison logic across screens.<\/p>\n\n\n\n<p>The third strength is user-facing display mapping. Technical fields become readable labels. That dramatically improves audit usability.<\/p>\n\n\n\n<p>The fourth strength is reference summarization. Users see meaningful context instead of raw IDs.<\/p>\n\n\n\n<p>The fifth strength is defensive parsing. Historical data is treated as potentially imperfect. The UI remains usable even when individual snapshots are unreadable.<\/p>\n\n\n\n<p>The sixth strength is testability. Most of the logic is deterministic and does not require WPF, database access, or UI infrastructure to test.<\/p>\n\n\n\n<p>This is exactly where desktop applications should invest: pure, stable, testable interpretation logic around critical product behavior.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Trade-Offs<\/h2>\n\n\n\n<p>No audit strategy is free.<\/p>\n\n\n\n<p>JSON snapshots are flexible and easy to implement, but they are less query-friendly than normalized change tables. If users need complex queries such as \u201cshow all changes where status changed from Draft to Submitted,\u201d normalized audit rows may be better.<\/p>\n\n\n\n<p>String-based comparison is simple and deterministic, but formatting changes can create false modifications. Stable snapshot formatting is therefore essential.<\/p>\n\n\n\n<p>Best-effort audit writing improves availability, but can create audit gaps. Strict transactional auditing improves integrity, but can block user operations when audit persistence fails.<\/p>\n\n\n\n<p>Rich UI formatting improves usability, but raw technical access remains valuable for diagnostics. A good audit screen should usually provide friendly display by default and raw payload access on demand.<\/p>\n\n\n\n<p>Snapshot storage can grow quickly. Retention policies, pruning, archiving, or compacting strategies may eventually be needed.<\/p>\n\n\n\n<p>These trade-offs do not weaken the architecture. They make the design honest.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Adoption Checklist<\/h2>\n\n\n\n<p>A practical audit implementation should answer these questions:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Are audit records append-only?<\/li>\n\n\n\n<li>Are timestamps stored in UTC?<\/li>\n\n\n\n<li>Is the actor captured where possible?<\/li>\n\n\n\n<li>Are old and new snapshots captured at the mutation boundary?<\/li>\n\n\n\n<li>Are snapshots stable and normalized?<\/li>\n\n\n\n<li>Are reference labels stored when they improve historical readability?<\/li>\n\n\n\n<li>Is diff logic centralized?<\/li>\n\n\n\n<li>Does the UI survive malformed or legacy JSON?<\/li>\n\n\n\n<li>Are technical field names mapped to user-facing labels?<\/li>\n\n\n\n<li>Are large histories paginated?<\/li>\n\n\n\n<li>Are diff components unit-tested?<\/li>\n\n\n\n<li>Is the audit guarantee documented as best-effort or transactional?<\/li>\n<\/ul>\n\n\n\n<p>If these questions have clear answers, the audit system is likely to remain maintainable.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Final Thoughts<\/h2>\n\n\n\n<p>An effective audit trail is not just a historical ledger.<\/p>\n\n\n\n<p>It is an explanation engine for business behavior.<\/p>\n\n\n\n<p>By combining repository-level change capture, JSON snapshots, centralized diff classification, reference summaries, and readable UI formatting, a desktop application can provide history that users actually understand.<\/p>\n\n\n\n<p>The strongest part of this architecture is the separation of responsibilities. Persistence stores the truth. The diff builder interprets snapshots. Display-name utilities translate technical fields. Summary formatters provide context. The UI presents the result without owning the audit logic.<\/p>\n\n\n\n<p>That is the right direction.<\/p>\n\n\n\n<p>Auditability should not be treated as an afterthought or a compliance checkbox. Designed well, it becomes a quality attribute of the product itself: traceability, accountability, confidence, and faster troubleshooting built directly into the application.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Designing Explainable Change History with JSON Snapshots Most applications can save data. Far fewer can explain what changed, when it changed, and why the current state looks the way it does. That difference matters. In many business applications, users eventually ask questions like: Who changed this entry?Why is this total different from yesterday?Was this item [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5,12,21,33,27],"tags":[],"class_list":["post-192","post","type-post","status-publish","format-standard","hentry","category-csharp","category-computer-science","category-learning","category-methods","category-programming-principles"],"_links":{"self":[{"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/posts\/192","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=192"}],"version-history":[{"count":1,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/posts\/192\/revisions"}],"predecessor-version":[{"id":195,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=\/wp\/v2\/posts\/192\/revisions\/195"}],"wp:attachment":[{"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=192"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=192"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.fabricioruch.ch\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=192"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}