Merge variants C0: every reader refuses what C1 will write, by name

C#, TypeScript, Python, Rust and CultMesh refuse an unknown
cultcache.store.* header and a fifth record slot, naming the version or the
key and schemaId. The silent slot-skipping and C#'s ignored header are
deleted, and CultMesh's second record decoder is gone. Shared vectors are in
tests/vectors/document-variants-c0/. v1 stores stay byte-identical. At base,
the single-file commit's read-modify-write rewrote a v2 store as v1, which
lost data; it now refuses.

Soul passed it. Cross-runtime interop was run and passes. Three C#
Caching tests fail on Linux at both base and tip: they are Windows
share-mode tests (a FileShare.Read handle blocking delete/replace), which
cannot pass on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 03:52:07 +02:00
commit e382bb4e20
20 changed files with 471 additions and 44 deletions

View file

@ -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;
}
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format it found.</summary>
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<byte>();
}
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)

View file

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

View file

@ -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<byte>();
}
for (var index = 4; index < fieldCount; index++)
reader.Skip();
return record;
}
private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
{
return catalog.Any(entry =>