diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md
index 72768673..46b1e77b 100644
--- a/docs/runtime-parity-scope.md
+++ b/docs/runtime-parity-scope.md
@@ -13,6 +13,8 @@ Cross-runtime parity has three layers:
- Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack
documents, transport profiles, and RUDP packets must cross runtime boundaries
without translation folklore.
+ Every store reader refuses, by name, a header or record slot it does not
+ understand (vectors: `tests/vectors/document-variants-c0`).
- Feature parity: each runtime must expose the CultCache/CultNet/CultMesh
surfaces needed for its intended role, with unsupported production-server
ownership called out plainly.
diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index 1e99125e..bb62d2ed 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -30,6 +30,7 @@ public sealed class CultDocumentResolver : IFormatterResolver
public static class CultDocumentMessagePackSerialization
{
+ private const string SingleFileFormatVersion = "cultcache.store.v1";
private const int PersistedRecordFieldCount = 4;
private const int SchemaCatalogEntryFieldCount = 7;
private const int SchemaCatalogMemberFieldCount = 8;
@@ -139,7 +140,8 @@ public static class CultDocumentMessagePackSerialization
if (fieldCount > 0)
{
- snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1";
+ snapshot.FormatVersion = reader.ReadString()
+ ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only.");
}
if (fieldCount > 1)
@@ -170,6 +172,17 @@ public static class CultDocumentMessagePackSerialization
return snapshot;
}
+ /// Refuses a single-file snapshot this runtime cannot read, naming the format it found.
+ public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot)
+ {
+ if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal))
+ {
+ throw new NotSupportedException(
+ $"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " +
+ "The store needs a runtime that resolves document variants.");
+ }
+ }
+
private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
{
writer.WriteArrayHeader(PersistedRecordFieldCount);
@@ -179,7 +192,7 @@ public static class CultDocumentMessagePackSerialization
writer.Write(record.Payload);
}
- private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
+ public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
{
var fieldCount = reader.ReadArrayHeader();
var record = new CultPersistedRecord();
@@ -204,9 +217,11 @@ public static class CultDocumentMessagePackSerialization
record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty();
}
- for (var index = PersistedRecordFieldCount; index < fieldCount; index++)
+ if (fieldCount > PersistedRecordFieldCount)
{
- reader.Skip();
+ throw new NotSupportedException(
+ $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " +
+ "The store needs a runtime that resolves document variants.");
}
return record;
@@ -371,7 +386,9 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
- return CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
+ var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
+ CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
+ return snapshot;
}
protected override byte[] SerializePayload(object document)
diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
index e8231c10..4511f3c5 100644
--- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
+++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
@@ -212,6 +212,11 @@ Example:
The payload is the domain object only. It does not repeat the key, schema id,
or stored timestamp.
+Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any
+other `cultcache.store.*` header and any record with more than four slots, naming the
+version or the record's key and schema id. It never skips a slot it does not
+understand.
+
## Canonical Semantic Schema Hash
`schemaId` should be derived from a canonical semantic schema hash.
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 5e2295df..46bfb47e 100644
--- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
+++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
@@ -236,14 +236,18 @@ namespace GameCult.Mesh
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{
var bytes = File.ReadAllBytes(path);
+ CultPersistedStoreSnapshot snapshot;
try
{
- return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
+ snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
}
catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException)
{
- return ReadLegacySingleFileSnapshot(bytes);
+ snapshot = ReadLegacySingleFileSnapshot(bytes);
}
+
+ CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
+ return snapshot;
}
private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes)
@@ -252,7 +256,8 @@ namespace GameCult.Mesh
var fieldCount = reader.ReadArrayHeader();
var snapshot = new CultPersistedStoreSnapshot();
if (fieldCount > 0)
- snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1";
+ snapshot.FormatVersion = reader.ReadString()
+ ?? throw new NotSupportedException("Store snapshot declares no format version.");
if (fieldCount > 1)
{
@@ -267,7 +272,7 @@ namespace GameCult.Mesh
var recordCount = reader.ReadArrayHeader();
snapshot.Records = new CultPersistedRecord[recordCount];
for (var index = 0; index < recordCount; index++)
- snapshot.Records[index] = ReadLegacyPersistedRecord(ref reader);
+ snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader);
}
for (var index = 3; index < fieldCount; index++)
@@ -303,23 +308,6 @@ namespace GameCult.Mesh
return entry;
}
- private static CultPersistedRecord ReadLegacyPersistedRecord(ref MessagePackReader reader)
- {
- var fieldCount = reader.ReadArrayHeader();
- var record = new CultPersistedRecord();
- if (fieldCount > 0) record.Key = reader.ReadString() ?? string.Empty;
- if (fieldCount > 1) record.SchemaId = reader.ReadString() ?? string.Empty;
- if (fieldCount > 2) record.StoredAt = reader.ReadString() ?? string.Empty;
- if (fieldCount > 3)
- {
- var payload = reader.ReadBytes();
- record.Payload = payload.HasValue ? payload.Value.ToArray() : Array.Empty();
- }
- for (var index = 4; index < fieldCount; index++)
- reader.Skip();
- return record;
- }
-
private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
{
return catalog.Any(entry =>
diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
index 6089b94e..6757a806 100644
--- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
+++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.IO;
+using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@@ -83,6 +84,73 @@ namespace GameCult.Caching.Tests
}
}
+ // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
+ private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
+
+ private static string VectorPath(string name)
+ {
+ for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
+ {
+ var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name);
+ if (File.Exists(candidate)) return candidate;
+ }
+ throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
+ }
+
+ private static async Task PullRefusalAsync(string vector)
+ {
+ var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ try
+ {
+ var file = Path.Combine(root, "store.msgpack");
+ File.Copy(VectorPath(vector), file);
+ using var cache = new CultCache();
+ cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
+ var error = Assert.CatchAsync(async () => await cache.PullAllBackingStoresAsync());
+ Assert.That(error, Is.Not.Null);
+ Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException)));
+ return error.ToString();
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Test]
+ public async Task SingleFileRefusesUnknownHeaderByName()
+ {
+ Assert.That(await PullRefusalAsync("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
+ }
+
+ [Test]
+ public async Task SingleFileRefusesExtraRecordSlotNamingTheRecord()
+ {
+ var message = await PullRefusalAsync("extra-slot-full-payload.msgpack");
+ Assert.That(message, Does.Contain("item:anvil"));
+ Assert.That(message, Does.Contain(ItemSchemaId));
+ }
+
+ [Test]
+ public async Task SingleFileRefusesVariantStoreByVersionOrRecord()
+ {
+ Assert.That(await PullRefusalAsync("variant-v2.msgpack"),
+ Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big"));
+ }
+
+ [Test]
+ public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte()
+ {
+ var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(VectorPath("v1-base.msgpack")));
+ CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
+
+ Assert.That(snapshot.FormatVersion, Is.EqualTo("cultcache.store.v1"));
+ Assert.That(snapshot.Records.Select(record => record.Key), Is.EqualTo(new[] { "alpha", "beta" }));
+ Assert.That(snapshot.Records[0].Payload, Is.EqualTo(new byte[] { 0x92, 0xa5, (byte)'a', (byte)'l', (byte)'p', (byte)'h', (byte)'a', 0x01 }));
+ Assert.That(snapshot.Records[1].Payload, Is.EqualTo(new byte[] { 0x92, 0xa4, (byte)'b', (byte)'e', (byte)'t', (byte)'a', 0x02 }));
+ }
+
// Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5.
[TestCase("cultcache.store.v1")]
[TestCase("cultcache.store.v1.directory")]
diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
new file mode 100644
index 00000000..7636cacf
--- /dev/null
+++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
@@ -0,0 +1,47 @@
+using System;
+using System.IO;
+using FluentAssertions;
+using GameCult.Caching;
+using NUnit.Framework;
+
+#nullable enable
+
+namespace GameCult.Mesh.Tests;
+
+// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
+public sealed class CultMeshVariantRefusalTests
+{
+ private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
+
+ private static string VectorPath(string name)
+ {
+ for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
+ {
+ var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name);
+ if (File.Exists(candidate)) return candidate;
+ }
+ throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
+ }
+
+ private static NotSupportedException Refusal(string vector) =>
+ Assert.Throws(() =>
+ CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!;
+
+ [Test]
+ public void ReadSingleFileDocument_RefusesUnknownHeaderByName() =>
+ Refusal("unknown-header.msgpack").Message.Should().Contain("cultcache.store.v9");
+
+ [Test]
+ public void ReadSingleFileDocument_RefusesExtraRecordSlotNamingTheRecord()
+ {
+ var message = Refusal("extra-slot-full-payload.msgpack").Message;
+ message.Should().Contain("item:anvil").And.Contain(ItemSchemaId);
+ }
+
+ [Test]
+ public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord()
+ {
+ var message = Refusal("variant-v2.msgpack").Message;
+ (message.Contains("cultcache.store.v2") || message.Contains("item:anvil-big")).Should().BeTrue(message);
+ }
+}