C#: compatible schema ids publish a schema on open, Mesh takes only the older layout, the refusal names its file

A record whose schema id the catalog lists only as a compatible id now opens (the cache resolved a persisted schema by entry id
alone). CultMesh's fallback to the older catalog-entry reader fires only for a store in that layout, decided by the entry's slot 5,
not for any decode failure, so a current-layout store with a malformed slot is refused as CultCache refuses it.
CultStoreUnreadableException.Path names the refused file. New vectors: compatible-id-only-v3 (accepted by all four) and three
current-layout catalogs with one malformed slot. The contract states the writer's invariant.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 06:54:17 +02:00
commit 969d78de39
16 changed files with 118 additions and 16 deletions

View file

@ -234,7 +234,7 @@ public static class CultDocumentMessagePackSerialization
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
throw new CultStoreUnreadableException(ex.Message, ex);
throw new CultStoreUnreadableException(ex.Message, innerException: ex);
}
}
@ -268,7 +268,7 @@ public static class CultDocumentMessagePackSerialization
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", ex);
throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", innerException: ex);
}
if (!whole.End)

View file

@ -451,7 +451,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
};
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
CultPersistedStoreSnapshot snapshot;
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
}
catch (CultStoreUnreadableException ex) when (ex.Path == null)
{
throw new CultStoreUnreadableException(ex.Message, _manifestFile.FullName, ex.InnerException);
}
if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
!string.Equals(snapshot.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal))
{

View file

@ -122,6 +122,10 @@ A reader never recovers a schema from a record's payload: a record whose schema
catalog does not publish (by id or as a compatible id) makes the store unreadable, whatever
the payload's first field says. The catalog is the store's own description of itself.
A writer keeps the same invariant: every record it writes carries a schema id that some catalog
entry it writes publishes, as that entry's id or as one of its compatible ids. A cache stamps a
record with its registered schema's id, whatever id the record arrived under.
This is not decorative paperwork. It is what allows another CultCache
implementation to inspect an old store, resolve the schema for each record, and
decide whether it can:

View file

@ -529,7 +529,9 @@ namespace GameCult.Caching
});
}
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal));
// A schema is published by its id or as a compatible id.
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))
?? catalog.FirstOrDefault(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal));
if (persisted == null)
{
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
@ -3601,7 +3603,16 @@ namespace GameCult.Caching
private CultPersistedStoreSnapshot? ReadSnapshot()
{
var bytes = ReadDisk();
return bytes == null ? null : DeserializeSnapshot(bytes);
if (bytes == null)
return null;
try
{
return DeserializeSnapshot(bytes);
}
catch (CultStoreUnreadableException ex) when (ex.Path == null)
{
throw new CultStoreUnreadableException(ex.Message, FileInfo.FullName, ex.InnerException);
}
}
private byte[]? ReadDisk()

View file

@ -6,13 +6,17 @@ namespace GameCult.Caching
/// A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a value that is not a
/// store, a missing or extra top-level slot), a format or record it does not know, or a body it cannot decode. Open, flush,
/// commit and CultMesh's single-file reads all refuse a file with this one exception, and a refused file is left as it was.
/// The underlying cause, where there is one, is the inner exception.
/// The underlying cause, where there is one, is the inner exception, and <see cref="Path"/> names the file.
/// </summary>
public sealed class CultStoreUnreadableException : NotSupportedException
{
public CultStoreUnreadableException(string message, Exception? innerException = null)
public CultStoreUnreadableException(string message, string? path = null, Exception? innerException = null)
: base(message, innerException)
{
Path = path;
}
/// <summary>The store file that was refused, or null when the bytes were read without a file.</summary>
public string? Path { get; }
}
}

View file

@ -270,10 +270,9 @@ namespace GameCult.Mesh
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
// Every refusal is a CultStoreUnreadableException. A store whose catalog entries are in the older layout is read by the
// same store reader with the older entry reader, so it is judged by the same framing, slot count and records. Only a
// catalog entry the current layout cannot decode (a MessagePack or shape failure) sends it there; a store refused for its
// framing, its slot count or a record keeps that refusal.
// Every refusal is a CultStoreUnreadableException naming the file. A store whose catalog entries are in the older layout is
// read by the same store reader with the older entry reader, so it is judged by the same framing, slot count and records.
// Only a store that is in the older layout goes there; any other refusal stands as the current reader gave it.
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{
var bytes = File.ReadAllBytes(path);
@ -282,17 +281,55 @@ namespace GameCult.Mesh
CultPersistedStoreSnapshot snapshot;
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
}
catch (CultStoreUnreadableException) when (HasOlderCatalogLayout(bytes))
{
snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
}
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
}
catch (CultStoreUnreadableException ex) when (ex.InnerException is MessagePackSerializationException or InvalidOperationException)
catch (CultStoreUnreadableException ex) when (ex.Path == null)
{
snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
throw new CultStoreUnreadableException(ex.Message, path, ex.InnerException);
}
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}
// The older layout puts the content hash, a string, at catalog entry slot 5, where the current layout has the array of
// compatible schema ids.
private static bool HasOlderCatalogLayout(byte[] bytes)
{
try
{
var reader = new MessagePackReader(bytes);
if (reader.ReadArrayHeader() != 3)
return false;
reader.Skip();
var entries = reader.ReadArrayHeader();
for (var entry = 0; entry < entries; entry++)
{
var fields = reader.ReadArrayHeader();
for (var field = 0; field < fields; field++)
{
if (field == 5 && reader.NextMessagePackType == MessagePackType.String)
return true;
reader.Skip();
}
}
return false;
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
return false;
}
}
private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(ref MessagePackReader reader)
{
var fieldCount = reader.ReadArrayHeader();

View file

@ -123,6 +123,15 @@ namespace GameCult.Caching.Tests
Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
}
// Slots of the current catalog layout that do not decode are refused by name, as a malformed store is.
[TestCase("current-catalog-bad-canonical.msgpack")]
[TestCase("current-catalog-bad-members.msgpack")]
[TestCase("current-catalog-bad-compat.msgpack")]
public void SingleFileRefusesAMalformedCurrentCatalogEntry(string vector)
{
Assert.That(Refusal(vector), Is.Not.Empty);
}
[Test]
public void SingleFileRefusesExtraRecordSlotNamingTheRecord()
{

View file

@ -71,7 +71,7 @@ namespace GameCult.Caching.Tests
if (reads)
Assert.DoesNotThrow(Open);
else
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>());
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
}
[TestCaseSource(nameof(Vectors))]
@ -116,6 +116,7 @@ namespace GameCult.Caching.Tests
// A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3.
[TestCase("../v3-base.msgpack", "cultcache.store.v3")]
[TestCase("compatible-id-only-v3.bin", "cultcache.store.v3")]
[TestCase("zero-byte.bin", "cultcache.store.v1")]
[TestCase("empty-array.bin", "cultcache.store.v1")]
public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header)

View file

@ -56,6 +56,16 @@ public sealed class CultMeshVariantRefusalTests
Refusal("legacy-catalog-trailing.msgpack").Message.Should().Contain("after its MessagePack array");
}
// A store in the current layout with one catalog slot malformed is refused as CultCache refuses it. It is not the older layout,
// so it is not read with the older reader, which would take the malformed canonical schema slot for a slot to skip.
[TestCase("current-catalog-bad-canonical.msgpack")]
[TestCase("current-catalog-bad-members.msgpack")]
[TestCase("current-catalog-bad-compat.msgpack")]
public void ReadSingleFileDocument_RefusesAMalformedCurrentCatalogEntry(string vector)
{
Refusal(vector).Path.Should().Be(VectorPath(vector));
}
// Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain
// records of the same v2 store.
[Test]

View file

@ -12,6 +12,12 @@ at test time. Run: python generate.py (needs msgpack).
legacy reader accepts
legacy-catalog-trailing.msgpack legacy-catalog-plain plus three bytes after the array: one array is
required of the legacy layout too
current-catalog-bad-canonical.msgpack
current-catalog-bad-members.msgpack
current-catalog-bad-compat.msgpack
v1 header, plain records, and the first catalog entry of the current layout with one slot
malformed (canonical schema json an int; members an int; a compatible id an int). Not the
older layout, so CultMesh refuses them as CultCache does
v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib
69a21bb (SingleFileMessagePackBackingStore.push of alpha and
beta, type vectors.item, before any C0 code existed)
@ -49,3 +55,10 @@ write("legacy-catalog-extra-slot.msgpack",
write("legacy-catalog-plain.msgpack", [header, [legacy(entry) for entry in catalog], records])
(here / "legacy-catalog-trailing.msgpack").write_bytes(
msgpack.packb([header, [legacy(entry) for entry in catalog], records], use_bin_type=True) + b"\x01\x02\x03")
current = catalog[0]
for name, entry in (
("current-catalog-bad-canonical", current[:4] + [5]),
("current-catalog-bad-members", current[:6] + [5]),
("current-catalog-bad-compat", current[:5] + [[7]] + current[6:]),
):
write(name + ".msgpack", [header, [entry] + catalog[1:], records])

View file

@ -0,0 +1 @@
“²cultcache.store.v3‘—±vectors.item.next¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta

View file

@ -39,6 +39,8 @@ vectors = {
"missing-schema-v3": pack([base[0], [], base[2]]),
# The same, with each payload opening with its schema version: no runtime recovers a schema from a payload.
"missing-schema-versioned-v3": pack([base[0], [], [record[:3] + [pack(["vectors.item.v1", "n", 1])] for record in base[2]]]),
# The record names its schema by an id the catalog publishes only as a compatible id of an entry with another id.
"compatible-id-only-v3": pack([base[0], [[base[1][0][0] + ".next"] + base[1][0][1:]], base[2]]),
# v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload, overriding the name. Only C# reads it.
"variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]),
# v3-base with a fourth top-level slot after the records.

View file

@ -23,3 +23,4 @@ extra-top-slot.bin refuses refuses refuses refuses
missing-schema-v3.bin refuses refuses refuses refuses
variant-slot-v3.bin reads refuses refuses refuses
missing-schema-versioned-v3.bin refuses refuses refuses refuses
compatible-id-only-v3.bin reads reads reads reads