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

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

View file

@ -14,6 +14,8 @@ from .backing_store import (
)
STORE_FORMAT_VERSION = "cultcache.store.v1"
_STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4
class JsonLinesBackingStore:
@ -153,7 +155,14 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION:
if not isinstance(decoded, list) or not decoded:
return None
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
raise ValueError(
f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. "
"The store needs a runtime that resolves document variants."
)
if decoded[0] != STORE_FORMAT_VERSION:
return None
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array")
@ -169,7 +178,12 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
for raw_record in decoded[2]:
if not isinstance(raw_record, list) or len(raw_record) < 4:
raise ValueError("CultCache persisted records must be MessagePack arrays")
key, schema_id, stored_at, payload = raw_record[:4]
if len(raw_record) > _PERSISTED_RECORD_SLOTS:
raise ValueError(
f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; "
f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants."
)
key, schema_id, stored_at, payload = raw_record
if not isinstance(key, str) or not key:
raise ValueError("CultCache persisted records must declare a key")
if not isinstance(schema_id, str) or not schema_id:

View file

@ -802,5 +802,39 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
# Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0"
_ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"
def _pull_vector(self, name: str) -> list[CultCacheEnvelope]:
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes((self._VECTORS / name).read_bytes())
return SingleFileMessagePackBackingStore(store_path).pull_all()
def test_single_file_refuses_unknown_header_by_name(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("unknown-header.msgpack")
self.assertIn("cultcache.store.v9", str(caught.exception))
def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("extra-slot-full-payload.msgpack")
self.assertIn("item:anvil", str(caught.exception))
self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception))
def test_single_file_refuses_variant_store_by_version_or_record(self) -> None:
with self.assertRaises(ValueError) as caught:
self._pull_vector("variant-v2.msgpack")
message = str(caught.exception)
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
envelopes = self._pull_vector("v1-base.msgpack")
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
if __name__ == "__main__":
unittest.main()

View file

@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember(
Option<String>,
);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Debug, serde::Serialize)]
struct PersistedRecord(
String,
String,
@ -279,6 +279,67 @@ struct PersistedRecord(
#[serde(with = "serde_bytes")] Vec<u8>,
);
impl<'de> serde::Deserialize<'de> for PersistedRecord {
/// Reads the four v1 slots and refuses a fifth, naming the record. A record with more
/// slots was written by a runtime that resolves document variants.
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
use serde::de::{Error, IgnoredAny, SeqAccess, Visitor};
struct RecordVisitor;
impl<'de> Visitor<'de> for RecordVisitor {
type Value = PersistedRecord;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a CultCache persisted record of 4 slots")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<PersistedRecord, A::Error> {
let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?;
let payload: serde_bytes::ByteBuf =
seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?;
if seq.next_element::<IgnoredAny>()?.is_some() {
return Err(A::Error::custom(format!(
"CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants."
)));
}
Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec()))
}
}
deserializer.deserialize_seq(RecordVisitor)
}
}
/// The store header string, or `None` when the bytes are not a snapshot-shaped array
/// (the legacy envelope array starts with a map).
fn store_header(bytes: &[u8]) -> Option<String> {
struct Header(String);
impl<'de> serde::Deserialize<'de> for Header {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
struct HeaderVisitor;
impl<'de> serde::de::Visitor<'de> for HeaderVisitor {
type Value = Header;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an array whose first slot is the store header")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Header, A::Error> {
let header: String = seq
.next_element()?
.ok_or_else(|| <A::Error as serde::de::Error>::invalid_length(0, &self))?;
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(Header(header))
}
}
deserializer.deserialize_seq(HeaderVisitor)
}
}
rmp_serde::from_slice::<Header>(bytes).ok().map(|header| header.0)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PushAllOptions {
pub soft: bool,
@ -658,9 +719,11 @@ impl SingleFileMessagePackBackingStore {
if bytes.is_empty() {
return Ok(Vec::new());
}
decode_store_snapshot(&bytes)
.or_else(|_| rmp_serde::from_slice(&bytes))
.with_context(|| format!("failed to decode MessagePack {}", self.path.display()))
match store_header(&bytes) {
Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes),
_ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from),
}
.map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display()))
}
/// Reads one filesystem snapshot without creating or opening the sibling
@ -2518,11 +2581,15 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result<PersistedStore
}
fn decode_store_snapshot(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
if let Some(header) = store_header(bytes) {
if header != "cultcache.store.v1" {
return Err(anyhow!(
"CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants."
));
}
}
let snapshot: PersistedStoreSnapshot =
rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?;
if snapshot.0 != "cultcache.store.v1" {
return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0));
}
let catalog = snapshot
.1
@ -4573,4 +4640,52 @@ mod tests {
assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]);
Ok(())
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
fn pull_vector(name: &str) -> Result<Vec<CultCacheEnvelope>> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("store.msgpack");
let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/document-variants-c0")
.join(name);
std::fs::copy(vector, &path)?;
SingleFileMessagePackBackingStore::new(&path).pull_all()
}
fn refusal(name: &str) -> String {
format!("{:#}", pull_vector(name).unwrap_err())
}
#[test]
fn single_file_refuses_unknown_header_by_name() {
assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9"));
}
#[test]
fn single_file_refuses_extra_record_slot_naming_the_record() {
let message = refusal("extra-slot-full-payload.msgpack");
assert!(message.contains("item:anvil"), "{message}");
assert!(message.contains(ITEM_SCHEMA_ID), "{message}");
}
#[test]
fn single_file_refuses_variant_store_by_version_or_record() {
let message = refusal("variant-v2.msgpack");
assert!(
message.contains("cultcache.store.v2") || message.contains("item:anvil-big"),
"{message}"
);
}
#[test]
fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> {
let envelopes = pull_vector("v1-base.msgpack")?;
let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect();
assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]);
assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01");
assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02");
Ok(())
}
}

View file

@ -1,5 +1,7 @@
import { decode } from "@msgpack/msgpack";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection {
filePath: string;
fileSizeBytes: number;
@ -39,8 +41,6 @@ export interface InspectedRecord {
payloadDecodeError?: string;
}
const STORE_FORMAT_VERSION = "cultcache.store.v1";
export function inspectCultCacheBytes(
filePath: string,
bytes: Uint8Array,
@ -197,6 +197,7 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, Inspected
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
@ -349,10 +350,6 @@ function toJsonSafe(value: unknown): unknown {
return value;
}
function isV1Snapshot(value: unknown): value is unknown[] {
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
import type {
CacheBackingStore,
CultCacheEnvelope,
@ -12,8 +13,6 @@ import type {
PushAllOptions,
} from "./types";
const STORE_FORMAT_VERSION = "cultcache.store.v1";
const envelopeSchema = z.object({
key: z.string().min(1),
type: z.string().min(1),
@ -285,7 +284,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
}
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
if (!isV1Snapshot(decoded)) {
return undefined;
}
@ -436,6 +435,7 @@ function decodeRecord(value: unknown): PersistedRecord {
throw new Error("CultCache persisted records must be MessagePack arrays.");
}
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");

View file

@ -0,0 +1,33 @@
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
const STORE_FORMAT_PREFIX = "cultcache.store.";
const PERSISTED_RECORD_SLOTS = 4;
/**
* True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy
* envelope array). Any other `cultcache.store.*` header is refused by name.
*/
export function isV1Snapshot(decoded: unknown): decoded is unknown[] {
if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) {
return false;
}
if (decoded[0] !== STORE_FORMAT_VERSION) {
throw new Error(
`CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` +
"The store needs a runtime that resolves document variants.",
);
}
return true;
}
/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */
export function requireV1RecordSlots(record: unknown[]): void {
if (record.length > PERSISTED_RECORD_SLOTS) {
throw new Error(
`CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` +
`this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`,
);
}
}

View file

@ -1413,3 +1413,48 @@ async function runJsonCommand(
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0");
const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
async function pullVector(name: string): Promise<CultCacheEnvelope[]> {
const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-"));
const file = join(dir, "store.msgpack");
await writeFile(file, await readFile(join(variantVectors, name)));
return new SingleFileMessagePackBackingStore(file).pullAll();
}
test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => {
await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u);
});
test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => {
await assert.rejects(
() => pullVector("extra-slot-full-payload.msgpack"),
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
);
});
test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => {
await assert.rejects(
() => pullVector("variant-v2.msgpack"),
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"),
);
});
test("CultCache inspector refuses the same vectors by name", async () => {
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
await assert.rejects(
() => inspect("extra-slot-full-payload.msgpack"),
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
);
});
test("a v1 store written at the base commit still reads byte for byte", async () => {
const envelopes = await pullVector("v1-base.msgpack");
assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]);
assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
});

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 =>

View file

@ -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,82 @@ 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 string Refusal(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();
var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)));
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 void SingleFileRefusesUnknownHeaderByName()
{
Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
}
[Test]
public void SingleFileRefusesExtraRecordSlotNamingTheRecord()
{
var message = Refusal("extra-slot-full-payload.msgpack");
Assert.That(message, Does.Contain("item:anvil"));
Assert.That(message, Does.Contain(ItemSchemaId));
}
[Test]
public void SingleFileRefusesVariantStoreByVersionOrRecord()
{
Assert.That(Refusal("variant-v2.msgpack"),
Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big"));
}
[Test]
public void SnapshotWithoutAFormatVersionIsRefusedNotDefaultedToV1()
{
// [nil, [], []]
var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 };
Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes),
Throws.TypeOf<NotSupportedException>().With.Message.Contains("format version"));
}
[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")]

View file

@ -0,0 +1,54 @@
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<NotSupportedException>(() =>
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_RefusesExtraRecordSlotInTheLegacyCatalogLayoutToo()
{
var message = Refusal("legacy-catalog-extra-slot.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);
}
}

View file

@ -0,0 +1,3 @@
# Refusal vectors are byte-exact; never convert line endings.
* binary
*.py text

View file

@ -0,0 +1,41 @@
"""Generates the shared C0 refusal vectors from the pre-Cut 2 v1 store (written before any
variant code existed). Every runtime's tests read the committed bytes; nothing regenerates them
at test time. Run: python generate.py (needs msgpack).
unknown-header.msgpack header cultcache.store.v9, otherwise the v1 store
extra-slot-full-payload.msgpack v1 header, record item:anvil gains slot 4, payload stays full
legacy-catalog-extra-slot.msgpack
v1 header, catalog entry in the older layout (contentHash at slot
5), so CultMesh's legacy snapshot reader takes it, plus the
extra-slot record
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)
variant-v2.msgpack what C1 writes for a variant: header v2, record slot 4 =
[baseKey, overrides[]], empty payload (override op Set = 0,
path steps are [slot, elementId])
"""
import pathlib
import msgpack
here = pathlib.Path(__file__).resolve().parent
source = here.parent.parent / "GameCult.Caching.Tests" / "Fixtures" / "pre-cut2" / "single-file-v1" / "store.msgpack"
header, catalog, records = msgpack.unpackb(source.read_bytes(), raw=False)
assert header == "cultcache.store.v1"
anvil = records[0]
assert anvil[0] == "item:anvil"
def write(name, snapshot):
(here / name).write_bytes(msgpack.packb(snapshot, use_bin_type=True))
write("unknown-header.msgpack", ["cultcache.store.v9", catalog, records])
write("extra-slot-full-payload.msgpack",
[header, catalog, [anvil + [["item:bellows", []]]] + records[1:]])
variant = ["item:anvil-big", anvil[1], anvil[2], b"", ["item:anvil", [[0, [[1, ""]], "", 99]]]]
write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]])
legacy_entry = [catalog[0][0], catalog[0][1], catalog[0][2], catalog[0][4], None, catalog[0][3], []]
write("legacy-catalog-extra-slot.msgpack",
[header, [legacy_entry], [anvil + [["item:bellows", []]]]])

View file

@ -0,0 +1 @@
“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c<30>‘•ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil’¬item:bellows<77>

View file

@ -0,0 +1 @@
“²cultcache.store.v1‘—¬vectors.item¬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

Binary file not shown.