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 - Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack
documents, transport profiles, and RUDP packets must cross runtime boundaries documents, transport profiles, and RUDP packets must cross runtime boundaries
without translation folklore. 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 - Feature parity: each runtime must expose the CultCache/CultNet/CultMesh
surfaces needed for its intended role, with unsupported production-server surfaces needed for its intended role, with unsupported production-server
ownership called out plainly. ownership called out plainly.

View file

@ -14,6 +14,8 @@ from .backing_store import (
) )
STORE_FORMAT_VERSION = "cultcache.store.v1" STORE_FORMAT_VERSION = "cultcache.store.v1"
_STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4
class JsonLinesBackingStore: class JsonLinesBackingStore:
@ -153,7 +155,14 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: 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 return None
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list): 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") 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]: for raw_record in decoded[2]:
if not isinstance(raw_record, list) or len(raw_record) < 4: if not isinstance(raw_record, list) or len(raw_record) < 4:
raise ValueError("CultCache persisted records must be MessagePack arrays") 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: if not isinstance(key, str) or not key:
raise ValueError("CultCache persisted records must declare a key") raise ValueError("CultCache persisted records must declare a key")
if not isinstance(schema_id, str) or not schema_id: 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") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember(
Option<String>, Option<String>,
); );
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize)]
struct PersistedRecord( struct PersistedRecord(
String, String,
String, String,
@ -279,6 +279,67 @@ struct PersistedRecord(
#[serde(with = "serde_bytes")] Vec<u8>, #[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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PushAllOptions { pub struct PushAllOptions {
pub soft: bool, pub soft: bool,
@ -658,9 +719,11 @@ impl SingleFileMessagePackBackingStore {
if bytes.is_empty() { if bytes.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
decode_store_snapshot(&bytes) match store_header(&bytes) {
.or_else(|_| rmp_serde::from_slice(&bytes)) Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes),
.with_context(|| format!("failed to decode MessagePack {}", self.path.display())) _ => 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 /// 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>> { 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 = let snapshot: PersistedStoreSnapshot =
rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?; 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 let catalog = snapshot
.1 .1
@ -4573,4 +4640,52 @@ mod tests {
assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]);
Ok(()) 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 { decode } from "@msgpack/msgpack";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection { export interface CultCacheInspection {
filePath: string; filePath: string;
fileSizeBytes: number; fileSizeBytes: number;
@ -39,8 +41,6 @@ export interface InspectedRecord {
payloadDecodeError?: string; payloadDecodeError?: string;
} }
const STORE_FORMAT_VERSION = "cultcache.store.v1";
export function inspectCultCacheBytes( export function inspectCultCacheBytes(
filePath: string, filePath: string,
bytes: Uint8Array, bytes: Uint8Array,
@ -197,6 +197,7 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, Inspected
throw new Error("CultCache persisted records must be MessagePack arrays."); throw new Error("CultCache persisted records must be MessagePack arrays.");
} }
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value; const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) { if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt."); throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
@ -349,10 +350,6 @@ function toJsonSafe(value: unknown): unknown {
return value; 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> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; 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 { decode, encode } from "@msgpack/msgpack";
import { z } from "zod"; import { z } from "zod";
import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format";
import type { import type {
CacheBackingStore, CacheBackingStore,
CultCacheEnvelope, CultCacheEnvelope,
@ -12,8 +13,6 @@ import type {
PushAllOptions, PushAllOptions,
} from "./types"; } from "./types";
const STORE_FORMAT_VERSION = "cultcache.store.v1";
const envelopeSchema = z.object({ const envelopeSchema = z.object({
key: z.string().min(1), key: z.string().min(1),
type: z.string().min(1), type: z.string().min(1),
@ -285,7 +284,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
} }
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) { if (!isV1Snapshot(decoded)) {
return undefined; return undefined;
} }
@ -436,6 +435,7 @@ function decodeRecord(value: unknown): PersistedRecord {
throw new Error("CultCache persisted records must be MessagePack arrays."); throw new Error("CultCache persisted records must be MessagePack arrays.");
} }
requireV1RecordSlots(value);
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value; const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) { if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
throw new Error("CultCache persisted records must declare key, schemaId, and 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); 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 public static class CultDocumentMessagePackSerialization
{ {
private const string SingleFileFormatVersion = "cultcache.store.v1";
private const int PersistedRecordFieldCount = 4; private const int PersistedRecordFieldCount = 4;
private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogEntryFieldCount = 7;
private const int SchemaCatalogMemberFieldCount = 8; private const int SchemaCatalogMemberFieldCount = 8;
@ -139,7 +140,8 @@ public static class CultDocumentMessagePackSerialization
if (fieldCount > 0) 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) if (fieldCount > 1)
@ -170,6 +172,17 @@ public static class CultDocumentMessagePackSerialization
return snapshot; 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) private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
{ {
writer.WriteArrayHeader(PersistedRecordFieldCount); writer.WriteArrayHeader(PersistedRecordFieldCount);
@ -179,7 +192,7 @@ public static class CultDocumentMessagePackSerialization
writer.Write(record.Payload); writer.Write(record.Payload);
} }
private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
{ {
var fieldCount = reader.ReadArrayHeader(); var fieldCount = reader.ReadArrayHeader();
var record = new CultPersistedRecord(); var record = new CultPersistedRecord();
@ -204,9 +217,11 @@ public static class CultDocumentMessagePackSerialization
record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty<byte>(); 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; return record;
@ -371,7 +386,9 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) 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) 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, The payload is the domain object only. It does not repeat the key, schema id,
or stored timestamp. 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 ## Canonical Semantic Schema Hash
`schemaId` should be derived from a 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) private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{ {
var bytes = File.ReadAllBytes(path); var bytes = File.ReadAllBytes(path);
CultPersistedStoreSnapshot snapshot;
try try
{ {
return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
} }
catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) 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) private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes)
@ -252,7 +256,8 @@ namespace GameCult.Mesh
var fieldCount = reader.ReadArrayHeader(); var fieldCount = reader.ReadArrayHeader();
var snapshot = new CultPersistedStoreSnapshot(); var snapshot = new CultPersistedStoreSnapshot();
if (fieldCount > 0) 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) if (fieldCount > 1)
{ {
@ -267,7 +272,7 @@ namespace GameCult.Mesh
var recordCount = reader.ReadArrayHeader(); var recordCount = reader.ReadArrayHeader();
snapshot.Records = new CultPersistedRecord[recordCount]; snapshot.Records = new CultPersistedRecord[recordCount];
for (var index = 0; index < recordCount; index++) 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++) for (var index = 3; index < fieldCount; index++)
@ -303,23 +308,6 @@ namespace GameCult.Mesh
return entry; 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) private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
{ {
return catalog.Any(entry => return catalog.Any(entry =>

View file

@ -1,6 +1,7 @@
#nullable enable #nullable enable
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using GameCult.Caching; using GameCult.Caching;
using GameCult.Caching.MessagePack; 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. // Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5.
[TestCase("cultcache.store.v1")] [TestCase("cultcache.store.v1")]
[TestCase("cultcache.store.v1.directory")] [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.