From 5f0b471443b4aa1d474ff9862eb057ca79ca6e40 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 2 Aug 2026 17:06:10 +0200 Subject: [PATCH] Replaced the duplicated payload hash formulas with one canonical definition --- .../VisualBriefingArtifactService.Assembly.cs | 8 +- ...sualBriefingBuildOrchestrator.Recompile.cs | 7 +- .../VisualBriefingContentStage.cs | 14 +-- .../VisualBriefingEvidenceStage.cs | 7 +- .../VisualBriefing/VisualBriefingHashing.cs | 14 +++ .../VisualBriefingPayloadHash.cs | 102 ++++++++++++++++++ .../VisualBriefing/VisualBriefingPlanStage.cs | 4 +- .../VisualBriefingPresentationStage.cs | 6 +- .../VisualBriefingStore.Builds.cs | 36 ++----- .../VisualBriefingStore.Versions.cs | 26 ++--- 10 files changed, 143 insertions(+), 81 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPayloadHash.cs diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs index ba01d31d..37ef1833 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Assembly.cs @@ -105,7 +105,13 @@ public sealed partial class VisualBriefingArtifactService /// /// Encodes the stable JSON artifact header for embedding in an HTML comment. /// - private static string EncodeHeader(VisualBriefingExportManifest exportManifest) => Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(exportManifest, JSON_OPTIONS))); + /// + /// The header is canonical JSON because verifying a stored briefing encodes it again and compares + /// the document hash. Plain serialization would tie every stored document to the order in which the + /// manifest properties happen to be declared, so moving one property would reject every briefing + /// ever exported. + /// + private static string EncodeHeader(VisualBriefingExportManifest exportManifest) => Convert.ToBase64String(Encoding.UTF8.GetBytes(VisualBriefingHashing.CanonicalJson(exportManifest))); /// /// Defines RuntimeAIVersionRegex for the visual briefing feature. diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs index c2b670ab..dbd5adf1 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingBuildOrchestrator.Recompile.cs @@ -150,12 +150,7 @@ internal sealed partial class VisualBriefingBuildOrchestrator { ArtifactId = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, - PayloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(previousPresentation.Layout, VisualBriefingJson.Canonical), - previousPresentation.Profile.ToString(), - compiled.TemplateHash, - compiled.CssHash), - + PayloadHash = VisualBriefingPayloadHash.ForPresentation(previousPresentation.Layout, previousPresentation.Profile, compiled.TemplateHash, compiled.CssHash), Layout = previousPresentation.Layout, Profile = previousPresentation.Profile, TemplateHtml = compiled.TemplateHtml, diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingContentStage.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingContentStage.cs index a7bfb80c..15215855 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingContentStage.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingContentStage.cs @@ -67,18 +67,8 @@ internal sealed class VisualBriefingContentStage(StructuredLlmStageRunner stageR }, }, VisualBriefingJson.Canonical); - // Keep in sync with the verification in VisualBriefingStore.ReadContentArtifactAsync: - artifact.PayloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(artifact.Slots, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Charts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Controls, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Formulas, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.AccessibilityTexts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.SourceReferences, VisualBriefingJson.Canonical), - artifact.ResetLabel, - JsonSerializer.Serialize(artifact.SourceCoverage, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.AssetPlan, VisualBriefingJson.Canonical), - artifact.StructuralSignature); + artifact.PayloadHash = VisualBriefingPayloadHash.ForContent(artifact.Slots, artifact.Charts, artifact.Controls, artifact.Formulas, artifact.AccessibilityTexts, + artifact.SourceReferences, artifact.ResetLabel, artifact.SourceCoverage, artifact.AssetPlan, artifact.StructuralSignature); await store.WriteContentArtifactAsync(manifest.BriefingId, artifact, token); build.ContentArtifactId = artifact.ArtifactId; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceStage.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceStage.cs index 4d59927c..46791db1 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceStage.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEvidenceStage.cs @@ -46,12 +46,7 @@ internal sealed class VisualBriefingEvidenceStage(StructuredLlmStageRunner stage await FailAsync(store, build, stage, run, VisualBriefingValidationRule.REFERENCE_INVALID, token); var response = run.Response!; - var payloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(response.Facts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(response.Metrics, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(response.Tables, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(response.SourceCoverage, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(response.AssetPlan, VisualBriefingJson.Canonical)); + var payloadHash = VisualBriefingPayloadHash.ForEvidence(response.Facts, response.Metrics, response.Tables, response.SourceCoverage, response.AssetPlan); var artifact = new VisualBriefingEvidenceArtifact { diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingHashing.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingHashing.cs index 31c5f022..fbae19fd 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingHashing.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingHashing.cs @@ -42,6 +42,20 @@ internal static class VisualBriefingHashing return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, token)); } + /// + /// Returns canonical JSON for one value, with ordinally sorted object properties. + /// + /// + /// Hashed values go through here instead of being serialized directly. Plain serialization writes + /// properties in declaration order, which would tie every stored hash to the order in which the + /// members happen to appear in the C# file: reordering two properties would invalidate every + /// briefing already on disk, without any visible change to the data. + /// + /// The type of the value to canonicalize. + /// The value to canonicalize. + /// Compact canonical JSON. + internal static string CanonicalJson(T value) => CanonicalJson(JsonSerializer.SerializeToElement(value, VisualBriefingJson.Canonical)); + /// /// Returns canonical JSON with ordinally sorted object properties. /// diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPayloadHash.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPayloadHash.cs new file mode 100644 index 00000000..893e5772 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPayloadHash.cs @@ -0,0 +1,102 @@ +namespace AIStudio.Assistants.VisualBriefing; + +/// +/// Computes the payload hashes that decide whether a stored intermediate artifact is still usable. +/// +/// +/// Each formula lives here exactly once. The stage that writes an artifact and the store that reads it +/// back have to agree on the sections down to their order, and they used to spell the formula out on +/// both sides with a comment asking the next developer to keep them aligned. A single misplaced section +/// makes the store discard every stored artifact of that kind, and it reports that as a missing +/// artifact rather than as an error, so the mistake surfaces as a briefing that silently refuses to be +/// reused. Sections are canonical JSON, which additionally makes the hashes independent of the order in +/// which the artifact properties are declared. +/// +internal static class VisualBriefingPayloadHash +{ + /// + /// Computes the payload hash of an evidence artifact. + /// + /// The extracted facts. + /// The extracted metrics. + /// The extracted tables. + /// The per-source coverage. + /// The planned visual assets. + /// The payload hash. + internal static string ForEvidence( + List facts, + List metrics, + List tables, + List sourceCoverage, + List assetPlan) => + VisualBriefingHashing.ComputeSections( + VisualBriefingHashing.CanonicalJson(facts), + VisualBriefingHashing.CanonicalJson(metrics), + VisualBriefingHashing.CanonicalJson(tables), + VisualBriefingHashing.CanonicalJson(sourceCoverage), + VisualBriefingHashing.CanonicalJson(assetPlan)); + + /// + /// Computes the payload hash of a plan artifact. + /// + /// The planned sections. + /// The structural signature of the plan. + /// The payload hash. + internal static string ForPlan( + List sections, + string structuralSignature) => VisualBriefingHashing.ComputeSections(VisualBriefingHashing.CanonicalJson(sections), structuralSignature); + + /// + /// Computes the payload hash of a content artifact. + /// + /// The filled content slots. + /// The chart specifications. + /// The interactive control specifications. + /// The formula specifications. + /// The accessibility texts per component. + /// The source references per component. + /// The localized reset label. + /// The per-source coverage. + /// The planned visual assets. + /// The structural signature of the business data. + /// The payload hash. + internal static string ForContent( + List slots, + List charts, + List controls, + List formulas, + Dictionary accessibilityTexts, + Dictionary> sourceReferences, + string resetLabel, + List sourceCoverage, + List assetPlan, + string structuralSignature) => + VisualBriefingHashing.ComputeSections( + VisualBriefingHashing.CanonicalJson(slots), + VisualBriefingHashing.CanonicalJson(charts), + VisualBriefingHashing.CanonicalJson(controls), + VisualBriefingHashing.CanonicalJson(formulas), + VisualBriefingHashing.CanonicalJson(accessibilityTexts), + VisualBriefingHashing.CanonicalJson(sourceReferences), + resetLabel, + VisualBriefingHashing.CanonicalJson(sourceCoverage), + VisualBriefingHashing.CanonicalJson(assetPlan), + structuralSignature); + + /// + /// Computes the payload hash of a presentation artifact. + /// + /// The compiled layout tree. + /// The design profile. + /// The hash of the compiled template. + /// The hash of the compiled CSS. + /// The payload hash. + internal static string ForPresentation( + VisualBriefingLayoutNode layout, + VisualBriefingDesignProfile profile, + string templateHash, + string cssHash) => + VisualBriefingHashing.ComputeSections( + VisualBriefingHashing.CanonicalJson(layout), + profile.ToString(), templateHash, cssHash); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanStage.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanStage.cs index 0b7d7fca..b3c6a029 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanStage.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPlanStage.cs @@ -50,8 +50,6 @@ internal sealed class VisualBriefingPlanStage(StructuredLlmStageRunner stageRunn await VisualBriefingEvidenceStage.FailAsync(store, build, stage, run, VisualBriefingValidationRule.REFERENCE_INVALID, token); var sections = run.Response!.Sections; - var payload = JsonSerializer.Serialize(sections, VisualBriefingJson.Canonical); - var structuralSignature = VisualBriefingHashing.Compute(string.Join('\u001f', sections.Select(section => $"{section.SectionId}:{section.Role}:{section.TitleSlotId}:{section.SummarySlotId}") .Concat(sections.SelectMany(section => section.Components) .Select(component => @@ -61,7 +59,7 @@ internal sealed class VisualBriefingPlanStage(StructuredLlmStageRunner stageRunn { ArtifactId = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, - PayloadHash = VisualBriefingHashing.ComputeSections(payload, structuralSignature), + PayloadHash = VisualBriefingPayloadHash.ForPlan(sections, structuralSignature), Sections = sections, StructuralSignature = structuralSignature, Model = VisualBriefingModelNames.ExportLabel(provider), diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPresentationStage.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPresentationStage.cs index a4846a08..9f66d464 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPresentationStage.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPresentationStage.cs @@ -80,11 +80,7 @@ internal sealed class VisualBriefingPresentationStage(StructuredLlmStageRunner s } var compiled = VisualBriefingLayoutCompiler.Compile(plan, content, run.Response.Layout, run.Response.Profile); - var payloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(run.Response.Layout, VisualBriefingJson.Canonical), - run.Response.Profile.ToString(), - compiled.TemplateHash, - compiled.CssHash); + var payloadHash = VisualBriefingPayloadHash.ForPresentation(run.Response.Layout, run.Response.Profile, compiled.TemplateHash, compiled.CssHash); var artifact = new VisualBriefingPresentationArtifact { diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Builds.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Builds.cs index e1076672..786fa80a 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Builds.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Builds.cs @@ -163,13 +163,7 @@ public sealed partial class VisualBriefingStore artifact.ArtifactId != artifactId) return null; - var hash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(artifact.Facts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Metrics, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Tables, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.SourceCoverage, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.AssetPlan, VisualBriefingJson.Canonical)); - + var hash = VisualBriefingPayloadHash.ForEvidence(artifact.Facts, artifact.Metrics, artifact.Tables, artifact.SourceCoverage, artifact.AssetPlan); return string.Equals(hash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } @@ -216,10 +210,8 @@ public sealed partial class VisualBriefingStore artifact.ArtifactId != artifactId) return null; - var hash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(artifact.Sections, VisualBriefingJson.Canonical), - artifact.StructuralSignature); - + var hash = VisualBriefingPayloadHash.ForPlan(artifact.Sections, artifact.StructuralSignature); + return string.Equals(hash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } @@ -272,18 +264,9 @@ public sealed partial class VisualBriefingStore string.IsNullOrWhiteSpace(artifact.ResetLabel)) return null; - var payloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(artifact.Slots, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Charts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Controls, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.Formulas, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.AccessibilityTexts, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.SourceReferences, VisualBriefingJson.Canonical), - artifact.ResetLabel, - JsonSerializer.Serialize(artifact.SourceCoverage, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(artifact.AssetPlan, VisualBriefingJson.Canonical), - artifact.StructuralSignature); - + var payloadHash = VisualBriefingPayloadHash.ForContent(artifact.Slots, artifact.Charts, artifact.Controls, artifact.Formulas, artifact.AccessibilityTexts, + artifact.SourceReferences, artifact.ResetLabel, artifact.SourceCoverage, artifact.AssetPlan, artifact.StructuralSignature); + return string.Equals(payloadHash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } @@ -335,12 +318,7 @@ public sealed partial class VisualBriefingStore artifact.ArtifactId != artifactId) return null; - var payloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(artifact.Layout, VisualBriefingJson.Canonical), - artifact.Profile.ToString(), - artifact.TemplateHash, - artifact.CssHash); - + var payloadHash = VisualBriefingPayloadHash.ForPresentation(artifact.Layout, artifact.Profile, artifact.TemplateHash, artifact.CssHash); return string.Equals(payloadHash, artifact.PayloadHash, StringComparison.Ordinal) && string.Equals( VisualBriefingHashing.Compute(artifact.TemplateHtml), diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs index fa0dad9b..687cc6c9 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Versions.cs @@ -502,24 +502,10 @@ public sealed partial class VisualBriefingStore new() { SlotId = "imported_data", Value = businessData }, }; - // Section order and count must match ReadContentArtifactAsync exactly: - var contentHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(importedSlots, VisualBriefingJson.Canonical), - "[]", - "[]", - "[]", - "{}", - "{}", - "Reset", - JsonSerializer.Serialize(coverage, VisualBriefingJson.Canonical), - JsonSerializer.Serialize(assetPlan, VisualBriefingJson.Canonical), - structuralSignature); - var content = new VisualBriefingContentArtifact { ArtifactId = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, - PayloadHash = contentHash, Data = businessData, Slots = importedSlots, ResetLabel = "Reset", @@ -528,6 +514,12 @@ public sealed partial class VisualBriefingStore StructuralSignature = structuralSignature, Model = "Imported artifact", }; + + // An imported briefing carries no charts, controls, formulas, accessibility texts, or source + // references. Hashing the artifact itself keeps those empty sections in the right places + // without spelling them out as literals here. + content.PayloadHash = VisualBriefingPayloadHash.ForContent(content.Slots, content.Charts, content.Controls, content.Formulas, content.AccessibilityTexts, + content.SourceReferences, content.ResetLabel, content.SourceCoverage, content.AssetPlan, content.StructuralSignature); var importedLayout = new VisualBriefingLayoutNode { @@ -550,11 +542,7 @@ public sealed partial class VisualBriefingStore { ArtifactId = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, - PayloadHash = VisualBriefingHashing.ComputeSections( - JsonSerializer.Serialize(importedLayout, VisualBriefingJson.Canonical), - VisualBriefingDesignProfile.EDITORIAL.ToString(), - templateHash, - cssHash), + PayloadHash = VisualBriefingPayloadHash.ForPresentation(importedLayout, VisualBriefingDesignProfile.EDITORIAL, templateHash, cssHash), Layout = importedLayout, Profile = VisualBriefingDesignProfile.EDITORIAL, TemplateHtml = parts.TemplateHtml,