diff --git a/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs b/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs
index 441aabdd..c4b8f60d 100644
--- a/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs
+++ b/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Shouldly;
@@ -113,4 +115,97 @@ public void Catalog_EveryVerbResultType_HasMatchingTypesMapEntry()
}
dangling.ShouldBeEmpty(string.Join(Environment.NewLine, dangling));
}
+
+ ///
+ /// SHA-based freshness gate: fails CI when
+ /// artifacts/verb-output-schemas.json drifts from what the
+ /// current verb annotations would generate.
+ ///
+ ///
+ /// Both sides are canonicalized (object keys sorted, consistent
+ /// indentation) before hashing so that whitespace-only reformats
+ /// don't trigger false positives.
+ ///
+ ///
+ ///
+ /// NOTE: this test is RED until Mozart's [VerbResult] backfill
+ /// PR (squad/527-verbresult-attrs) lands and regenerates the
+ /// artifact. That is expected and intentional.
+ ///
+ ///
+ ///
+ /// If this test fails, run:
+ /// dotnet build src/Polyphony.SchemaExporter -c Release
+ ///
+ ///
+ [Fact]
+ public void VerbOutputSchemas_ArtifactIsFresh()
+ {
+ var repoRoot = FindRepoRoot();
+ var artifactPath = Path.Combine(repoRoot, "artifacts", "verb-output-schemas.json");
+
+ File.Exists(artifactPath).ShouldBeTrue(
+ $"artifacts/verb-output-schemas.json is missing. " +
+ $"Run: dotnet build src/Polyphony.SchemaExporter -c Release" +
+ $"{Environment.NewLine}Looked at: {artifactPath}");
+
+ var embeddedCanon = CanonicalizeJson(VerbOutputSchemaCatalog.Json);
+ var diskCanon = CanonicalizeJson(File.ReadAllText(artifactPath));
+
+ var embeddedSha = Sha256Hex(embeddedCanon);
+ var diskSha = Sha256Hex(diskCanon);
+
+ diskSha.ShouldBe(embeddedSha,
+ $"artifacts/verb-output-schemas.json is stale (SHA mismatch).{Environment.NewLine}" +
+ $" On-disk SHA : {diskSha}{Environment.NewLine}" +
+ $" Expected SHA: {embeddedSha}{Environment.NewLine}" +
+ $"Run: dotnet build src/Polyphony.SchemaExporter -c Release");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Helpers for freshness check
+ // ─────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Parse , sort all object keys recursively,
+ /// and re-serialize with consistent indentation. Two JSON documents
+ /// that are semantically equivalent will produce the same canonical
+ /// string regardless of original key order or whitespace.
+ ///
+ private static string CanonicalizeJson(string json)
+ {
+ var node = JsonNode.Parse(json)!;
+ return SortNode(node).ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ }
+
+ private static JsonNode SortNode(JsonNode node)
+ {
+ if (node is JsonObject obj)
+ {
+ var sorted = new JsonObject();
+ foreach (var kv in obj.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ {
+ sorted.Add(kv.Key, kv.Value is not null ? SortNode(kv.Value.DeepClone()) : null);
+ }
+ return sorted;
+ }
+
+ if (node is JsonArray arr)
+ {
+ var sortedArr = new JsonArray();
+ foreach (var item in arr)
+ {
+ sortedArr.Add(item is not null ? SortNode(item.DeepClone()) : null);
+ }
+ return sortedArr;
+ }
+
+ return node.DeepClone();
+ }
+
+ private static string Sha256Hex(string input)
+ {
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
+ return Convert.ToHexString(hash).ToLowerInvariant();
+ }
}