C2a format gate: the gate harness, a C# gate probe, leaf probes on the C2a-written vector, and the 1.0.60 ref-list probes

old-reader-refusal.sh gains gate <runtime> and per-runtime old revisions (b8fc5eb0 = cultlib-unity-v2.0.1,
02cf04b5 = cultcache-rs-v0.3.1, 45c2f400 = cultlib-unity-v1.0.60). C2aGateWriter writes the live v3 and v5 stores.
OldReaderGateProbe replaces OldReaderV3Probe. The vectors themselves follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 13:18:57 +02:00
commit de78734f4d
10 changed files with 696 additions and 100 deletions

View file

@ -0,0 +1,138 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The writer half of the C2a format gate. It writes a record that holds element ids through CultCacheMessagePack.Create, once
// as a single-file store and once as a directory store, and asserts the headers C2a stamps. old-reader-refusal.sh gate runs it
// with CULTLIB_GATE_OUT set, so the 2.0.1 reader probes the very bytes this tree writes; the committed vectors are one such run.
public class C2aGateWriter
{
private const string SingleName = "c2a-written-ids.msgpack";
private const string DirectoryName = "c2a-written-ids-directory";
private const string ManifestName = "store.cc";
private const string DirectoryHeader = "cultcache.store.v5.directory-content-addressed-pages";
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(ElementIdTests.IdDeck) });
// Every id is the author's, so the written bytes and the decoded ids do not depend on minting.
private static readonly string[] Ids = { "00000000c2a1", "00000000c2a2", "00000000c2a3", "00000000c2a4" };
private static ElementIdTests.IdDeck Deck() => new()
{
Name = "gate",
Reels =
{
new ElementIdTests.IdReel
{
Label = "reel",
Id = Ids[0],
Marks = { new ElementIdTests.IdMark { Text = "m", Id = Ids[1] }, new ElementIdTests.IdMark { Text = "m", Id = Ids[2] } }
}
},
Moves = { new ElementIdTests.IdSlash { Reach = 1, Id = Ids[3] } }
};
private static string[] IdsOf(ElementIdTests.IdDeck deck) =>
deck.Reels.Select(reel => reel.Id)
.Concat(deck.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id)))
.Concat(deck.Moves.Select(move => move.Id))
.ToArray();
private static void Write(string path, bool directory)
{
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory });
cache.Commit(batch => batch.Upsert(typeof(ElementIdTests.IdDeck), Deck(), new CultRecordKey("gate")));
}
private static string HeaderOf(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
private static string[] ReadIds(string path)
{
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
return IdsOf(cache.Get<ElementIdTests.IdDeck>(new CultRecordKey("gate"))!);
}
private static string Vectors()
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a");
if (Directory.Exists(vectors)) return vectors;
}
throw new DirectoryNotFoundException($"tests/vectors/document-variants-c2a not found above {TestContext.CurrentContext.TestDirectory}.");
}
private static void CopyTree(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
File.Copy(file, Path.Combine(to, Path.GetFileName(file)));
foreach (var directory in Directory.GetDirectories(from))
CopyTree(directory, Path.Combine(to, Path.GetFileName(directory)));
}
private string _temp = string.Empty;
[SetUp]
public void CreateTemp()
{
_temp = Path.Combine(Path.GetTempPath(), $"cultlib-c2a-gate-{Guid.NewGuid():N}");
Directory.CreateDirectory(_temp);
}
[TearDown]
public void DeleteTemp()
{
if (Directory.Exists(_temp))
Directory.Delete(_temp, recursive: true);
}
[Test]
public void C2aStampsV3OnASingleFileStoreAndV5OnADirectoryStoreThatHoldIds()
{
var output = Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT");
var root = string.IsNullOrEmpty(output) ? _temp : output;
Directory.CreateDirectory(root);
var single = Path.Combine(root, SingleName);
var directory = Path.Combine(root, DirectoryName);
File.Delete(single);
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
Directory.CreateDirectory(directory);
Write(single, directory: false);
Write(Path.Combine(directory, ManifestName), directory: true);
Assert.That(HeaderOf(single), Is.EqualTo(CultPersistedStoreSnapshot.FormatV3));
Assert.That(HeaderOf(Path.Combine(directory, ManifestName)), Is.EqualTo(DirectoryHeader));
Assert.That(ReadIds(single), Is.EqualTo(Ids));
}
[Test]
public void TheCommittedVectorsAreWhatC2aWrites()
{
var live = Path.Combine(_temp, "live");
Directory.CreateDirectory(live);
Write(Path.Combine(live, SingleName), directory: false);
Write(Path.Combine(live, ManifestName), directory: true);
var committedSingle = Path.Combine(Vectors(), SingleName);
var committedManifest = Path.Combine(Vectors(), DirectoryName, ManifestName);
Assert.That(HeaderOf(committedSingle), Is.EqualTo(HeaderOf(Path.Combine(live, SingleName))));
Assert.That(HeaderOf(committedManifest), Is.EqualTo(HeaderOf(Path.Combine(live, ManifestName))));
// The committed bytes are read through a copy: opening a store may take its lock beside it.
var copy = Path.Combine(_temp, "copy");
CopyTree(Vectors(), copy);
Assert.That(ReadIds(Path.Combine(copy, SingleName)), Is.EqualTo(ReadIds(Path.Combine(live, SingleName))));
Assert.That(ReadIds(Path.Combine(copy, DirectoryName, ManifestName)), Is.EqualTo(Ids));
}
}
}

View file

@ -106,6 +106,38 @@ namespace GameCult.Caching.Tests
}
}
// The store the 1.0.60 writer wrote (tests/vectors/ref-list-drift/v1-0-60-written.cc, made by OldWriterRefListProbe), or the one
// old-reader-refusal.sh gate csharp names in CULTLIB_GATE_OLD_STORE. The line it prints is the one OldReaderRefListProbe prints
// at 2.0.1; the gate diffs them.
[Test]
public async Task A_1_0_60_Written_Store_Opens_Like_2_0_1()
{
var source = Environment.GetEnvironmentVariable("CULTLIB_GATE_OLD_STORE");
var copy = Path.Combine(Path.GetTempPath(), $"reflist-1060-{Guid.NewGuid():N}.cc");
File.Copy(string.IsNullOrEmpty(source) ? VectorPath("v1-0-60-written.cc") : source, copy);
try
{
using var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore(copy);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
var warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray();
Assert.That(warnings, Does.Contain("gained_reference_semantics"));
var items = cache.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray();
Assert.That(items, Is.EqualTo(new[] { "alpha", "beta" }));
Assert.That(cache.Get<RefListTarget>(cache.GetAll<RefListHolder>().Single().Items[0].Key)!.Name, Is.EqualTo("alpha"));
store.PushAll();
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy));
var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray();
TestContext.Out.WriteLine("REFLIST-SUMMARY " + System.Text.Json.JsonSerializer.Serialize(new { records, items, warnings, header = snapshot.FormatVersion }));
}
finally
{
File.Delete(copy);
}
}
private static CultSchemaMigrationReport Resolve(Action<CultSchemaMemberCatalogEntry> persistedItems)
{
var catalog = CultDocumentRegistry.Shared.GetRequired<RefListHolder>().ToCatalogEntry();

View file

@ -0,0 +1,164 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite.
// The 2.0.1 reader meets the stores C2a writes. The source is "committed" (tests/vectors/document-variants-c2a) or, when
// CULTLIB_GATE_OUT is set, "live" (what C2aGateWriter just wrote at the tree under test). Every refusal names the format, and
// the store is byte-identical after it.
public class OldReaderGateProbe
{
private const string SingleName = "c2a-written-ids.msgpack";
private const string DirectoryName = "c2a-written-ids-directory";
private const string ManifestName = "store.cc";
[CultDocument("tests.gate_probe", "tests.gate_probe.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class GateProbeDocument
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
public static IEnumerable<string> Sources()
{
yield return "committed";
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")))
yield return "live";
}
private static string SourceRoot(string source) => source == "live"
? Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!
: Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_ROOT")!, "tests", "vectors", "document-variants-c2a");
private string _temp = string.Empty;
[SetUp]
public void CreateTemp()
{
_temp = Path.Combine(Path.GetTempPath(), $"cultlib-gate-{Guid.NewGuid():N}");
Directory.CreateDirectory(_temp);
}
[TearDown]
public void DeleteTemp()
{
if (Directory.Exists(_temp))
Directory.Delete(_temp, recursive: true);
}
private string CopySingle(string source, string name)
{
var file = Path.Combine(_temp, name);
File.Copy(Path.Combine(SourceRoot(source), SingleName), file);
return file;
}
private string CopyDirectory(string source, string name)
{
var root = Path.Combine(_temp, name);
CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root);
return Path.Combine(root, ManifestName);
}
private static void CopyTree(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var file in Directory.GetFiles(from))
File.Copy(file, Path.Combine(to, Path.GetFileName(file)));
foreach (var directory in Directory.GetDirectories(from))
CopyTree(directory, Path.Combine(to, Path.GetFileName(directory)));
}
// Every file under the root with its content hash, in a fixed order: a tree compares equal only if nothing in it moved.
private static string[] Tree(string root) =>
Directory.GetFiles(root, "*", SearchOption.AllDirectories)
.OrderBy(file => file, StringComparer.Ordinal)
.Select(file => Path.GetRelativePath(root, file) + " " + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))))
.ToArray();
private static void Names(Exception? error, string format, string what)
{
Assert.That(error, Is.Not.Null, what);
TestContext.Out.WriteLine($"OLD-READER csharp refused {what}: " + error!.Message);
Assert.That(error.ToString(), Does.Contain(format), what);
}
[TestCaseSource(nameof(Sources))]
public void V3IsRefusedAtAttach(string source)
{
var file = CopySingle(source, "attach.cc");
var before = File.ReadAllBytes(file);
using var cache = new CultCache();
Names(Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))), "cultcache.store.v3", "attach");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
[TestCaseSource(nameof(Sources))]
public void V3IsRefusedByPushAllOfAStoreThatWasNeverAttached(string source)
{
var file = CopySingle(source, "detached.cc");
var before = File.ReadAllBytes(file);
var store = new SingleFileMessagePackBackingStore(file);
Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll of a detached store");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
[TestCaseSource(nameof(Sources))]
public async Task V3SwappedInAfterAttachIsRefusedByFlushAndPushAll(string source)
{
var file = Path.Combine(_temp, "swap.cc");
using var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore(file);
cache.AddBackingStore(store);
await cache.AddAsync(new GateProbeDocument { Name = "staged" });
File.Copy(Path.Combine(SourceRoot(source), SingleName), file, overwrite: true);
var before = File.ReadAllBytes(file);
Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v3", "flush after a swap");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll after a swap");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
[TestCaseSource(nameof(Sources))]
public void V5IsRefusedByTheDirectoryStoreAtAttach(string source)
{
var manifest = CopyDirectory(source, "attach-directory");
var before = Tree(Path.GetDirectoryName(manifest)!);
using var cache = new CultCache();
Names(Assert.Catch(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest))), "cultcache.store.v5", "directory attach");
Assert.That(Tree(Path.GetDirectoryName(manifest)!), Is.EqualTo(before));
}
[TestCaseSource(nameof(Sources))]
public async Task V5SwappedInAfterAttachIsRefusedByTheDirectoryStoreAtFlush(string source)
{
var root = Path.Combine(_temp, "swap-directory");
Directory.CreateDirectory(root);
var manifest = Path.Combine(root, ManifestName);
using var cache = new CultCache();
var store = new DirectoryMessagePackBackingStore(manifest);
cache.AddBackingStore(store);
await cache.AddAsync(new GateProbeDocument { Name = "staged" });
CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root);
var before = Tree(root);
Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v5", "directory flush after a swap");
Assert.That(Tree(root), Is.EqualTo(before));
Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v5", "directory PushAll after a swap");
Assert.That(Tree(root), Is.EqualTo(before));
}
}
}

View file

@ -0,0 +1,67 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite.
// It attaches the store the 1.0.60 writer produced and prints the REFLIST-SUMMARY line that
// RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1 prints at head; 'gate csharp' diffs the two lines.
public class OldReaderRefListProbe
{
[CultDocument("tests.reflist_target", "tests.reflist_target.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListTarget
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListHolder
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
}
[Test]
public async Task A_1_0_60_Written_Store_Attaches_As_Compatible_Drift()
{
var copy = Path.Combine(Path.GetTempPath(), $"reflist-gate-{Guid.NewGuid():N}.cc");
File.Copy(Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!, "v1-0-60-ref-list.cc"), copy);
try
{
using var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore(copy);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
var warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray();
Assert.That(warnings, Does.Contain("gained_reference_semantics"));
var items = cache.GetAll<RefListHolder>().Single().Items.Select(item => item.Key.Value).ToArray();
Assert.That(items, Is.EqualTo(new[] { "alpha", "beta" }));
store.PushAll();
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy));
var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray();
TestContext.Out.WriteLine("REFLIST-SUMMARY " + JsonSerializer.Serialize(new { records, items, warnings, header = snapshot.FormatVersion }));
}
finally
{
File.Delete(copy);
}
}
}
}

View file

@ -1,34 +0,0 @@
#nullable enable
using System;
using System.IO;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite.
public class OldReaderV3Probe
{
[Test]
public void ACopyOfV3BaseIsRefusedByHeader()
{
var root = Path.Combine(Path.GetTempPath(), $"cultlib-oldreader-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
var file = Path.Combine(root, "store.msgpack");
File.Copy(Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_ROOT")!, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack"), file);
using var cache = new CultCache();
var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)));
Assert.That(error, Is.Not.Null);
TestContext.Out.WriteLine("OLD-READER csharp refused: " + error!.Message);
Assert.That(error.ToString(), Does.Contain("cultcache.store.v3"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
}
}

View file

@ -4,15 +4,42 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { documentPayloadFromBytes } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
// Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite.
test("old TypeScript reader refuses the v3 element-id store by header", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-oldreader-"));
const file = join(dir, "store.msgpack");
await writeFile(file, await readFile(resolve(__dirname, "../../../../tests/vectors/document-variants-c2a/v3-base.msgpack")));
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), (error: Error) => {
console.log("OLD-READER typescript refused: " + error.message);
// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite.
// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create.
const vector = resolve(__dirname, "../../../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack");
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
async function copyOfTheVector(): Promise<{ file: string; bytes: Buffer }> {
const bytes = await readFile(vector);
const file = join(await mkdtemp(join(tmpdir(), "cultcache-oldreader-")), "store.msgpack");
await writeFile(file, bytes);
return { file, bytes };
}
function refusesV3(label: string) {
return (error: Error) => {
console.log(`OLD-READER typescript refused ${label}: ` + error.message);
return /cultcache\.store\.v3/u.test(error.message);
});
};
}
test("old TypeScript reader refuses the C2a element-id store at read", async () => {
const { file, bytes } = await copyOfTheVector();
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), refusesV3("pullAll"));
assert.equal(Buffer.compare(await readFile(file), bytes), 0);
});
test("old TypeScript reader refuses a per-record push to the C2a element-id store", async () => {
const { file, bytes } = await copyOfTheVector();
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).push(envelope), refusesV3("push"));
assert.equal(Buffer.compare(await readFile(file), bytes), 0);
});
test("old TypeScript reader refuses a whole-store pushAll over the C2a element-id store", async () => {
const { file, bytes } = await copyOfTheVector();
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([envelope]), refusesV3("pushAll"));
assert.equal(Buffer.compare(await readFile(file), bytes), 0);
});

View file

@ -1,15 +1,53 @@
// Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite.
use cultcache_rs::{CacheBackingStore, SingleFileMessagePackBackingStore};
// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite.
// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create.
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions, SingleFileMessagePackBackingStore};
#[test]
fn old_rust_reader_refuses_the_v3_element_id_store_by_header() {
fn copy_of_the_vector() -> (tempfile::TempDir, std::path::PathBuf, Vec<u8>) {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("store.msgpack");
let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/document-variants-c2a/v3-base.msgpack");
.join("../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack");
std::fs::copy(vector, &path).unwrap();
let error = SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap_err();
let bytes = std::fs::read(&path).unwrap();
(temp, path, bytes)
}
fn envelope() -> CultCacheEnvelope {
CultCacheEnvelope {
key: "probe".into(),
r#type: "probe".into(),
payload: vec![0x80],
stored_at: "2026-10-22T00:00:00Z".into(),
schema_id: None,
}
}
fn refused_v3(label: &str, error: anyhow::Error) {
let message = format!("{error:#}");
println!("OLD-READER rust refused: {message}");
println!("OLD-READER rust refused {label}: {message}");
assert!(message.contains("cultcache.store.v3"), "{message}");
}
#[test]
fn old_rust_reader_refuses_the_c2a_element_id_store_at_read() {
let (_temp, path, bytes) = copy_of_the_vector();
refused_v3("pull_all", SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap_err());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
#[test]
fn old_rust_reader_refuses_a_per_record_push_to_the_c2a_element_id_store() {
let (_temp, path, bytes) = copy_of_the_vector();
refused_v3("push", SingleFileMessagePackBackingStore::new(&path).push(&envelope()).unwrap_err());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
#[test]
fn old_rust_reader_refuses_a_whole_store_push_all_over_the_c2a_element_id_store() {
let (_temp, path, bytes) = copy_of_the_vector();
let error = SingleFileMessagePackBackingStore::new(&path)
.push_all(std::slice::from_ref(&envelope()), PushAllOptions::default())
.unwrap_err();
refused_v3("push_all", error);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}

View file

@ -2,20 +2,39 @@ import tempfile
import unittest
from pathlib import Path
from cultcache_py import SingleFileMessagePackBackingStore
from cultcache_py import CultCacheEnvelope, SingleFileMessagePackBackingStore
# Copied into a checkout of the C0 merge (e382bb4) by old-reader-refusal.sh. Not part of this tree's suite.
# Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite.
# The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create.
class OldReaderV3(unittest.TestCase):
def test_old_python_reader_refuses_the_v3_element_id_store_by_header(self) -> None:
vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" / "v3-base.msgpack"
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes(vector.read_bytes())
with self.assertRaises(ValueError) as caught:
SingleFileMessagePackBackingStore(store_path).pull_all()
print("OLD-READER python refused:", caught.exception)
def setUp(self) -> None:
vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" / "c2a-written-ids.msgpack"
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.path = Path(self._tmp.name) / "store.msgpack"
self.bytes = vector.read_bytes()
self.path.write_bytes(self.bytes)
self.envelope = CultCacheEnvelope(key="probe", type="probe", payload=b"\x80", stored_at="2026-10-22T00:00:00Z")
def refuses_v3(self, label: str, action) -> None:
with self.assertRaises(ValueError) as caught:
action(SingleFileMessagePackBackingStore(self.path))
print(f"OLD-READER python refused {label}:", caught.exception)
self.assertIn("cultcache.store.v3", str(caught.exception))
self.assertEqual(self.path.read_bytes(), self.bytes)
def test_old_python_reader_refuses_the_c2a_element_id_store_at_read(self) -> None:
self.refuses_v3("pull_all", lambda store: store.pull_all())
def test_old_python_reader_refuses_a_per_record_push(self) -> None:
self.refuses_v3("push", lambda store: store.push(self.envelope))
def test_old_python_reader_refuses_a_whole_store_push_all(self) -> None:
self.refuses_v3("push_all", lambda store: store.push_all([self.envelope]))
def test_old_python_reader_refuses_a_delete(self) -> None:
self.refuses_v3("delete", lambda store: store.delete("probe", "probe"))
if __name__ == "__main__":

View file

@ -1,46 +1,128 @@
#!/usr/bin/env bash
# Proves that a reader from before element ids refuses the v3 header, per runtime. The old reader is the code at the C0
# merge (e382bb4), which accepts only cultcache.store.v1; OLD_READER_REV overrides it (the C1 merge, for the C# reader that
# reads v2).
# The C2a format gate: a reader from before element ids must refuse what C2a writes, and a store the 1.0.60 writer made must
# open alike at 2.0.1 and at the tree under test. Each old revision is a resolved SHA with its tag beside it, because the
# verification clone holds ancestors, not tags; an environment variable overrides each.
#
# old-reader-refusal.sh stage
# Run in a CultLib clone. Commits the old revision plus the v3 vector and the probes in old-reader-probes/ onto a
# scratch ref and prints its SHA. The tree at that SHA is the old reader with one extra test.
# old-reader-refusal.sh probe csharp|typescript|python|rust
# Run at the root of the staged tree (a verification job given that SHA), with the runtime's toolchain on PATH.
# Exit status zero means the old reader refused the header by name; the refusal message is printed.
# C# OLD_READER_REV_CSHARP b8fc5eb0 cultlib-unity-v2.0.1
# TypeScript, OLD_READER_REV_LEAF 02cf04b5 cultcache-rs-v0.3.1 (the whole-store writer fix; no cultcache-ts tag carries it)
# Python, Rust
# 1.0.60 writer OLD_WRITER_REV 45c2f400 cultlib-unity-v1.0.60
#
# old-reader-refusal.sh stage csharp|typescript|python|rust|writer
# Run in a CultLib clone. Commits the old revision plus the C2a-written vectors and that runtime's probes onto a scratch
# ref and prints its SHA. The tree at that SHA is the old code with extra tests ("writer" is the 1.0.60 writer).
# old-reader-refusal.sh probe csharp|typescript|python|rust|writer
# Run at the root of a staged tree, with the runtime's toolchain on PATH. Exit status zero means every probe passed; the
# refusal messages are printed.
# old-reader-refusal.sh gate csharp|typescript|python|rust
# Run in a CultLib clone at the revision under test: stage, then probe. For csharp, C2aGateWriter first writes the live
# v3 and v5 stores into $GATE_OUT (default /tmp/gate) and the 2.0.1 probes read them beside the committed vectors; the
# 1.0.60 writer then writes a ref-list store there, 2.0.1 and the tree under test both open it, and their REFLIST-SUMMARY
# lines must be identical.
set -euo pipefail
here="tests/vectors/document-variants-c2a"
self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
OLD_READER_REV_CSHARP="${OLD_READER_REV_CSHARP:-b8fc5eb0}" # cultlib-unity-v2.0.1
OLD_READER_REV_LEAF="${OLD_READER_REV_LEAF:-02cf04b5}" # cultcache-rs-v0.3.1
OLD_WRITER_REV="${OLD_WRITER_REV:-45c2f400}" # cultlib-unity-v1.0.60
usage() { echo "usage: $0 stage|probe|gate csharp|typescript|python|rust (writer for stage and probe)" >&2; exit 2; }
revision_for() {
case "$1" in
csharp) echo "$OLD_READER_REV_CSHARP" ;;
typescript|python|rust) echo "$OLD_READER_REV_LEAF" ;;
writer) echo "$OLD_WRITER_REV" ;;
*) usage ;;
esac
}
stage() {
local runtime="$1" root rev old sha
root="$(git rev-parse --show-toplevel)"
rev="$(revision_for "$runtime")"
old="$(mktemp -d)/old"
git -C "$root" worktree add --detach "$old" "$rev" >/dev/null
mkdir -p "$old/$here"
case "$runtime" in
csharp)
cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/"
cp -r "$root/$here/c2a-written-ids-directory" "$old/$here/"
cp "$root/$here/old-reader-probes/OldReaderGateProbe.cs" "$root/$here/old-reader-probes/OldReaderRefListProbe.cs" "$old/tests/GameCult.Caching.Tests/" ;;
typescript)
cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/"
cp "$root/$here/old-reader-probes/old-reader-v3.test.ts" "$old/packages/cultcache-ts/test/" ;;
python)
cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/"
cp "$root/$here/old-reader-probes/test_old_reader_v3.py" "$old/packages/cultcache-py/tests/" ;;
rust)
cp "$root/$here/c2a-written-ids.msgpack" "$old/$here/"
mkdir -p "$old/packages/cultcache-rs/tests"
cp "$root/$here/old-reader-probes/old_reader_v3.rs" "$old/packages/cultcache-rs/tests/" ;;
writer)
cp "$root/$here/old-writer-probes/OldWriterRefListProbe.cs" "$old/tests/GameCult.Caching.Tests/" ;;
esac
git -C "$old" add -A
git -C "$old" -c user.name=old-reader-refusal -c user.email=old-reader-refusal@localhost commit -q -m "$runtime probe over $rev"
sha="$(git -C "$old" rev-parse HEAD)"
git -C "$root" update-ref "refs/old-reader-probe/$sha" "$sha"
git -C "$root" worktree remove --force "$old"
echo "$sha"
}
probe() {
case "$1" in
csharp) CULTLIB_ROOT="$PWD" dotnet test tests/GameCult.Caching.Tests --filter "FullyQualifiedName~OldReaderGateProbe|FullyQualifiedName~OldReaderRefListProbe" --logger "console;verbosity=detailed" ;;
writer) dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~OldWriterRefListProbe --logger "console;verbosity=detailed" ;;
typescript)
npm install --no-audit --no-fund >/dev/null 2>&1
cd packages/cultcache-ts
node ../../node_modules/typescript/bin/tsc -p tsconfig.json
node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json
node --test dist-test/test/old-reader-v3.test.js ;;
python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;;
rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;;
*) usage ;;
esac
}
# Stage a runtime's probes, then run them in a worktree of the staged commit.
probe_staged() {
local sha tree
sha="$(stage "$1")"
tree="$(mktemp -d)/staged"
git worktree add --detach "$tree" "$sha" >/dev/null
(cd "$tree" && "$self" probe "$1")
git worktree remove --force "$tree"
}
summary_of() { grep -o 'REFLIST-SUMMARY .*' "$1" | sort -u; }
gate() {
case "$1" in
csharp)
local out="${GATE_OUT:-/tmp/gate}"
rm -rf "$out"; mkdir -p "$out"
export CULTLIB_GATE_OUT="$out"
dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~C2aGateWriter --logger "console;verbosity=detailed"
probe_staged writer
probe_staged csharp 2>&1 | tee "$out/reader.log"
CULTLIB_GATE_OLD_STORE="$out/v1-0-60-ref-list.cc" dotnet test tests/GameCult.Caching.Tests \
--filter "FullyQualifiedName~RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1" --logger "console;verbosity=detailed" 2>&1 | tee "$out/head.log"
summary_of "$out/reader.log" > "$out/reader.summary"
summary_of "$out/head.log" > "$out/head.summary"
test "$(wc -l < "$out/reader.summary")" -eq 1 && test "$(wc -l < "$out/head.summary")" -eq 1
cat "$out/reader.summary" "$out/head.summary"
diff "$out/reader.summary" "$out/head.summary" && echo "GATE csharp: the 2.0.1 and head REFLIST-SUMMARY lines are identical" ;;
typescript|python|rust) probe_staged "$1" ;;
*) usage ;;
esac
}
case "${1:-}" in
stage)
root="$(git rev-parse --show-toplevel)"
here="tests/vectors/document-variants-c2a"
old="$(mktemp -d)/old"
git -C "$root" worktree add --detach "$old" "${OLD_READER_REV:-e382bb4}" >/dev/null
mkdir -p "$old/$here"
cp "$root/$here/v3-base.msgpack" "$old/$here/"
cp "$root/$here/old-reader-probes/OldReaderV3Probe.cs" "$old/tests/GameCult.Caching.Tests/"
cp "$root/$here/old-reader-probes/old-reader-v3.test.ts" "$old/packages/cultcache-ts/test/"
cp "$root/$here/old-reader-probes/test_old_reader_v3.py" "$old/packages/cultcache-py/tests/"
mkdir -p "$old/packages/cultcache-rs/tests"
cp "$root/$here/old-reader-probes/old_reader_v3.rs" "$old/packages/cultcache-rs/tests/"
git -C "$old" add -A
git -C "$old" commit -q -m "old-reader probe over ${OLD_READER_REV:-e382bb4}"
sha="$(git -C "$old" rev-parse HEAD)"
git -C "$root" update-ref "refs/old-reader-probe/$sha" "$sha"
git -C "$root" worktree remove --force "$old"
echo "$sha" ;;
probe)
case "${2:-}" in
csharp) CULTLIB_ROOT="$PWD" dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~OldReaderV3Probe --logger "console;verbosity=detailed" ;;
typescript)
npm install --no-audit --no-fund >/dev/null 2>&1
cd packages/cultcache-ts
node ../../node_modules/typescript/bin/tsc -p tsconfig.json
node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json
node --test dist-test/test/old-reader-v3.test.js ;;
python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;;
rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;;
*) echo "usage: $0 probe csharp|typescript|python|rust" >&2; exit 2 ;;
esac ;;
*) echo "usage: $0 stage | probe <runtime>" >&2; exit 2 ;;
stage) stage "${2:-}" ;;
probe) probe "${2:-}" ;;
gate) gate "${2:-}" ;;
*) usage ;;
esac

View file

@ -0,0 +1,63 @@
#nullable enable
using System;
using System.IO;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Copied into a checkout of cultlib-unity-v1.0.60 (45c2f400) by old-reader-refusal.sh. Not part of this tree's suite.
// It writes the store the 1.0.x writer produces for a list of record references (the shapes are RefListDriftTests'), and
// is the only author of tests/vectors/ref-list-drift/v1-0-60-written.cc.
public class OldWriterRefListProbe
{
[CultDocument("tests.reflist_target", "tests.reflist_target.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListTarget
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class RefListHolder
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public CultRecordRef<RefListTarget>[] Items = Array.Empty<CultRecordRef<RefListTarget>>();
}
[Test]
public async Task WritesAStoreWhoseRefListSlotIsAPlainValue()
{
var output = Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!;
Directory.CreateDirectory(output);
var file = Path.Combine(output, "v1-0-60-ref-list.cc");
File.Delete(file);
using var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore(file);
cache.AddBackingStore(store);
await cache.AddAsync(new RefListTarget { Name = "alpha" });
await cache.AddAsync(new RefListTarget { Name = "beta" });
await cache.AddAsync(new RefListHolder
{
Name = "list",
Items = new[] { new CultRecordRef<RefListTarget>(new CultRecordKey("alpha")), new CultRecordRef<RefListTarget>(new CultRecordKey("beta")) }
});
store.PushAll();
var header = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)).FormatVersion;
Assert.That(header, Is.EqualTo("cultcache.store.v1"));
TestContext.Out.WriteLine("OLD-WRITER 1.0.60 wrote " + file + " as " + header);
}
}
}