diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 6a08bbec..87653470 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -206,12 +206,13 @@ The rule is checked again where the write lands, under the store's lock: a write whose durable record is not owned, because another writer rewrote it after this cache loaded, is refused typed, and the record is reloaded as the store holds it, read-only when a type can read it. This holds for every write through the store, the store's own `Push`, `Delete` and -`CommitBatch` included. A CultMesh single-file write asks the same rule: it owns its entry's own id and the ids that entry lists -compatible, and an id whose publishing entry its claimant reads whole (no persisted slot unread). The claimant is -the one a cache with the call's registry would pick, and it owns the record only when it is the entry the call writes, so a -cacheless write is never more permissive than a cache, nor than its own declaration: a record another registered type claims -is refused, under that type's current id or an old one it lists. With no registry the call's one entry is the claimant. It is -refused the same way otherwise. +`CommitBatch` included. A CultMesh single-file write asks the same rule: it owns the ids its written entries declare (their own ids and the ids they +list compatible) and an id whose publishing entry its claimant reads whole (no persisted slot unread). The claimant is picked by +the one precedence over the written entries and the call's registry, a written entry being the only description of its schema id +(the registry's type for that id is never asked), and the record is owned only when the claimant is a written entry. So a cacheless +write is never more permissive than its declaration, nor than a cache registering the registry's other types beside the written +ones: a record another registered type claims is refused, under that type's current id or an old one it lists, and a registry only +adds refusals. With no registry the written entries are the only claimants. It is refused the same way otherwise. A record that resolves to no local type is a foreign record, for example one of a type this build does not have, or one renamed under a new schema name without declaring its old id. A store never destroys or relabels it: the store carries it diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index dee7e227..b0771655 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -593,24 +593,45 @@ namespace GameCult.Caching public string TypeLabel { get; } } - // The lookup of a cacheless writer without a registry: the catalog entries the call writes. + // The lookup of a cacheless writer: the catalog entries the call writes, and the lent registry's other types. A written entry is + // the only description of its schema id, so the registry's type for that id is never asked: a registry adds claimants but never + // re-describes the written type. internal sealed class CatalogEntryLookup : IClaimantLookup { private readonly IReadOnlyCollection _entries; + private readonly IClaimantLookup? _others; - public CatalogEntryLookup(IReadOnlyCollection entries) => _entries = entries; + public CatalogEntryLookup(IReadOnlyCollection entries, CultDocumentRegistry? others) + { + _entries = entries; + _others = others?.Lookup; + } public CultSchemaCatalogEntry? Owner(string schemaId) => - _entries.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); + _entries.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)) + ?? Other(_others?.Owner(schemaId)); public IReadOnlyList Listing(string schemaId) => - _entries.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal)).ToArray(); + _entries.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal)) + .Concat(Others(_others?.Listing(schemaId))) + .ToArray(); public IReadOnlyList Named(string schemaName) => - _entries.Where(entry => string.Equals(entry.SchemaName, schemaName, StringComparison.Ordinal)).ToArray(); + _entries.Where(entry => string.Equals(entry.SchemaName, schemaName, StringComparison.Ordinal)) + .Concat(Others(_others?.Named(schemaName))) + .ToArray(); public ClaimantFacts Describe(CultSchemaCatalogEntry claimant) => new(claimant.SchemaName, claimant.SchemaVersion, claimant.SchemaId); + + // A registry type counts only when no written entry has its schema id. + private CultSchemaCatalogEntry? Other(CultDocumentDescriptor? descriptor) => + descriptor == null || _entries.Any(entry => string.Equals(entry.SchemaId, descriptor.SchemaId, StringComparison.Ordinal)) + ? null + : descriptor.ToCatalogEntry(); + + private IEnumerable Others(IEnumerable? descriptors) => + (descriptors ?? Array.Empty()).Select(Other).OfType(); } public sealed class CultDocumentRegistry @@ -817,12 +838,11 @@ namespace GameCult.Caching // next write. A record read only by dropping slots is not owned: declaring its id compatible is the consent to the loss. // Header only: the persisted catalog entry that publishes the id is compared with the claimant's entry, never a payload. A // claimant that cannot be named (ambiguous, id missing from the catalog, shapes incompatible) owns nothing. - internal bool Owns(string schemaId, IReadOnlyCollection catalog) => Owns(schemaId, catalog, Declared, _ => true); + internal bool Owns(string schemaId, IReadOnlyCollection catalog) => + Owns(schemaId, catalog, Declared, _indexes, descriptor => descriptor.ToCatalogEntry(), _ => true); - // The same rule with the declared half supplied: a cacheless writer lent this registry declares what it writes, and claims - // with the types this registry would, but only a claimant that is itself one of the entries it writes may own a record. - internal bool Owns(string schemaId, IReadOnlyCollection catalog, Func declared, Func mayClaim) => - Owns(schemaId, catalog, declared, _indexes, descriptor => descriptor.ToCatalogEntry(), mayClaim); + // The registry's lookup, for a cacheless writer that lends it only its other types. + internal IClaimantLookup Lookup => _indexes; // The rule itself, for any writer: declared, or the claimant (picked by the one Claimant over a lookup) reads the record's // published entry whole. The lookup is a registry's indexes, or the entries a registry-less cacheless writer writes. mayClaim @@ -4464,7 +4484,7 @@ namespace GameCult.Caching /// The record to store. /// The schema catalog entry for the record's schema id. /// Whether the record's payload holds element ids, or null when the caller cannot tell. - /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read, and the registry also picks the type that reads a durable record, as a cache registering it would; omitted, it decides nothing for them and the one catalog entry is the reader. It serves this call only: the store keeps no registry. + /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read, and its types also claim stored records as a cache registering them would, so it can only refuse more; stays the only description of its own schema id and the registry's type for that id is never asked. Omitted, it decides nothing for copied records and the one entry is the only claimant. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . /// The store is attached to a cache; write through the cache. /// The file at the path is not a store this runtime can read. @@ -4487,7 +4507,7 @@ namespace GameCult.Caching /// The record key the document is stored under. /// The document to store. /// The stored-at stamp, or null for now. - /// The registry that describes and encodes the document, or null for . It also picks the type that reads a durable record, as a cache registering it would, and that type owns the record only when it is the one written, with no compatible id the passed entry does not list; another registered type's record is refused, and so is a record whose stored entry lists another registered type's id the registry picks first. A stored entry that lists the written type's own id is owned only while no other registered type is picked first, so a wider registry (or Shared, when none is lent) can only refuse more, never own more. Own drift is owned only on those terms. It serves this call only: the store keeps no registry. + /// The registry that describes and encodes the document, or null for . Its types claim stored records as a cache registering it would: the document's type owns a record its entry does not declare only when it is the claimant and reads it whole; another registered type's record, or one whose stored entry lists another registered type's id that type claims first, is refused; a wider registry (Shared when none is passed) can only refuse more. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws , carried by the encoder's wrapper when the code ran inside it. /// The store is attached to a cache; write through the cache. /// The file at the path is not a store this runtime can read. @@ -4708,10 +4728,11 @@ namespace GameCult.Caching }); } - // A cache owns through its registry (CultDocumentRegistry.Owns). A cacheless CultMesh write owns what the one catalog entry it - // writes declares: its own id and the ids it lists compatible, or an id whose publishing entry its claimant reads whole, the - // claimant being the one a cache with the lent registry would pick (else that entry) and only an entry the call writes - // (variants:ruling:mesh-writes-bound-by-declaration). The keys judged are every key staged, written, removed or checked. + // A cache owns through its registry (CultDocumentRegistry.Owns). A cacheless CultMesh write owns what the catalog entries it + // writes declare: their own ids and the ids they list compatible, or an id whose publishing entry its claimant reads whole, + // the claimant being picked by the one precedence over the written entries and the lent registry's other types, and owning + // only when it is a written entry (variants:ruling:mesh-writes-bound-by-declaration). The keys judged are every key staged, + // written, removed or checked. private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals, IEnumerable checks) => RefuseUndeclaredOnDisk( durable, @@ -4719,9 +4740,9 @@ namespace GameCult.Caching OwnsOnDisk(upserts)); // The owns predicate a write over the durable catalog asks. A cacheless writer declares what the entries it writes declare - // (variants:ruling:mesh-writes-bound-by-declaration), and reads a published entry with the claimant a cache registering the - // same types would pick (its lent registry's, else the entries it writes), owning the record only when that claimant is an - // entry the call writes. It is never more permissive than that cache, nor than the declaration. + // (variants:ruling:mesh-writes-bound-by-declaration) and reads a published entry with one lookup: the written entries shadow + // the lent registry's type for their ids, and the registry adds other claimants. The record is owned only when the claimant + // is a written entry, so the call is never more permissive than that declaration, and a registry can only refuse more. private Func, bool> OwnsOnDisk(IReadOnlyCollection? upserts = null) => Cache != null || upserts == null ? Registry.Owns @@ -4731,12 +4752,13 @@ namespace GameCult.Caching Func declared = candidate => writes.Any(entry => string.Equals(entry.SchemaId, candidate, StringComparison.Ordinal) || entry.CompatibleSchemaIds.Contains(candidate, StringComparer.Ordinal)); - Func written = claimant => - writes.Any(entry => string.Equals(entry.SchemaId, claimant.SchemaId, StringComparison.Ordinal) && - claimant.CompatibleSchemaIds.All(id => entry.CompatibleSchemaIds.Contains(id, StringComparer.Ordinal))); - return AttachedRegistry is { } lent - ? lent.Owns(id, catalog, declared, written) - : CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes), entry => entry, written); + return CultDocumentRegistry.Owns( + id, + catalog, + declared, + new CatalogEntryLookup(writes, AttachedRegistry), + entry => entry, + claimant => writes.Any(entry => string.Equals(entry.SchemaId, claimant.SchemaId, StringComparison.Ordinal))); }; // A refused key's durable version is what this store has now read of it. diff --git a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs index 3ee99a33..ed4d1f41 100644 --- a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs +++ b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs @@ -556,49 +556,116 @@ namespace GameCult.Caching.Tests Assert.That(Fingerprint(path), Is.EqualTo(before)); } - // A direct WriteRecord is bound by the entry it passes: a lent registry whose entry for the same schema id lists an old id the - // passed entry omits does not widen it. The same write with the entry listing the id is owned, as a cache owns it. - [TestCase(false)] - [TestCase(true)] - public void ALentRegistryNeverWidensWhatThePassedEntryDeclares(bool entryListsOldId) + private const string ListerDriftId = "tests.restamp.lister_drift"; + + // The Lister type's entry as a caller passes it: its id's own listing, or the id list the caller chooses. + private static CultSchemaCatalogEntry ListerEntry(string[] compatible, CultSchemaMemberCatalogEntry[]? members = null, string hashPrefix = "") { - var path = SeedRenamed(listsNarrow: false, name: "tests.restamp_old"); - var snapshot = Snapshot(path); - snapshot.SchemaCatalog.Single().SchemaId = snapshot.SchemaCatalog.Single().ContentHash = ListerOldId; - snapshot.SchemaCatalog.Single().CompatibleSchemaIds = new[] { ListerOldId }; - foreach (var seeded in snapshot.Records) - seeded.SchemaId = ListerOldId; - File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); - var registry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteLister) }); - var full = registry.GetRequired().ToCatalogEntry(); - var passed = new CultSchemaCatalogEntry + var full = NarrowListerRegistry.GetRequired().ToCatalogEntry(); + return new CultSchemaCatalogEntry { SchemaId = full.SchemaId, - ContentHash = full.ContentHash, + ContentHash = hashPrefix + full.ContentHash, SchemaName = full.SchemaName, SchemaVersion = full.SchemaVersion, - CompatibleSchemaIds = entryListsOldId ? full.CompatibleSchemaIds : new[] { full.SchemaId }, - Members = full.Members, + CompatibleSchemaIds = compatible, + Members = members ?? full.Members, CanonicalSchemaJson = full.CanonicalSchemaJson, }; - var record = new CultPersistedRecord - { - Key = "note", - SchemaId = full.SchemaId, - StoredAt = "2026-10-22T00:00:00.0000000+00:00", - Payload = MessagePackSerializer.Serialize(new NoteLister { Name = "note", Value = 2 }), - }; + } + + private static CultPersistedRecord ListerRecord(byte[]? payload = null) => new() + { + Key = "note", + SchemaId = NarrowListerRegistry.GetRequired().SchemaId, + StoredAt = "2026-10-22T00:00:00.0000000+00:00", + Payload = payload ?? MessagePackSerializer.Serialize(new NoteLister { Name = "note", Value = 2 }), + }; + + // The one stored entry and its records renamed to the given id, name and compatible ids (ContentHash kept), Narrow-shaped. + private string SeedListerStore(string id, string name, params string[] compatible) + { + var path = SeedRenamed(listsNarrow: false, name: name); + var snapshot = Snapshot(path); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = id; + entry.CompatibleSchemaIds = compatible; + foreach (var seeded in snapshot.Records) + seeded.SchemaId = id; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // A direct WriteRecord is bound by the entry it passes, and by it alone: the entry the registry holds for the same schema id + // changes nothing, lent or not. An entry omitting the old id refuses the record; one listing it owns the record, as a cache would. + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void ThePassedEntryAloneDecidesTheWrittenTypesIds(bool entryListsOldId, bool lend) + { + var path = SeedListerStore(ListerOldId, "tests.restamp_old", ListerOldId); + var passed = ListerEntry(entryListsOldId ? new[] { NarrowListerRegistry.GetRequired().SchemaId, ListerOldId } : new[] { NarrowListerRegistry.GetRequired().SchemaId }); + var registry = lend ? CultDocumentRegistry.ForTypes(new[] { typeof(NoteLister) }) : null; var before = Fingerprint(path); if (entryListsOldId) { - new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed, null, registry); - Assert.That(Snapshot(path).Records.Single(stored => stored.Key == "note").SchemaId, Is.EqualTo(full.SchemaId)); + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, registry); + Assert.That(Snapshot(path).Records.Single(stored => stored.Key == "note").SchemaId, Is.EqualTo(passed.SchemaId)); return; } - Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed, null, registry)); + Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, registry)); Assert.That(Fingerprint(path), Is.EqualTo(before)); - Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed)); + } + + // The written type's own drift, read whole, is owned however the entry lists ids and whatever registry is lent: a registry + // that holds the written type never refuses what its own entry would own. + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void ALentRegistryNeverRefusesTheWrittenTypesOwnDrift(bool passedListsOwnId, bool lend) + { + var path = SeedListerStore(ListerDriftId, "tests.restamp_lister", ListerDriftId); + var own = NarrowListerRegistry.GetRequired().SchemaId; + var passed = ListerEntry(passedListsOwnId ? new[] { own } : Array.Empty()); + + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, lend ? NarrowListerRegistry : null); + + Assert.That(Snapshot(path).Records.Single(stored => stored.Key == "note").SchemaId, Is.EqualTo(own)); + } + + // A lent registry never widens the shape of the passed entry: an entry narrower than the stored one reads it only by dropping + // a slot, and the registry's wider entry for the same id is not asked. + [TestCase(false)] + [TestCase(true)] + public void ALentRegistryNeverWidensTheShapeOfThePassedEntry(bool lend) + { + var path = SeedListerStore(ListerDriftId, "tests.restamp_lister", ListerDriftId); + var passed = ListerEntry(new[] { NarrowListerRegistry.GetRequired().SchemaId }, ListerEntry(Array.Empty()).Members.Take(1).ToArray(), "narrow-"); + var before = Fingerprint(path); + + Assert.Throws(() => + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(MessagePackSerializer.Serialize(new[] { "note" })), passed, null, lend ? NarrowListerRegistry : null)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // The shadow, where it is decided: a written entry answers for its schema id whatever the registry holds, and the registry + // still adds its other types. + [Test] + public void AWrittenEntryShadowsTheLentRegistrysTypeForItsId() + { + var written = NarrowRegistry.GetRequired().ToCatalogEntry(); + var lookup = new CatalogEntryLookup(new[] { written }, NarrowListerRegistry); + var lister = NarrowListerRegistry.GetRequired(); + + Assert.That(lookup.Owner(written.SchemaId), Is.SameAs(written)); + Assert.That(lookup.Named(written.SchemaName), Is.EqualTo(new[] { written }).Using((x, y) => ReferenceEquals(x, y))); + Assert.That(lookup.Listing(written.SchemaId), Is.EqualTo(new[] { written }).Using((x, y) => ReferenceEquals(x, y))); + Assert.That(lookup.Owner(lister.SchemaId)!.SchemaId, Is.EqualTo(lister.SchemaId)); + Assert.That(lookup.Listing(ListerOldId).Select(entry => entry.SchemaId), Is.EqualTo(new[] { lister.SchemaId })); + Assert.That(lookup.Named(lister.SchemaName).Select(entry => entry.SchemaId), Is.EqualTo(new[] { lister.SchemaId })); } // The registry's other types change nothing about a record the written type claims itself. @@ -676,8 +743,8 @@ namespace GameCult.Caching.Tests var writes = new[] { narrow }; Func declares = id => id == narrow.SchemaId; - Assert.That(CultDocumentRegistry.Owns("tests.restamp.unpublished", writes, declares, new CatalogEntryLookup(writes), entry => entry, _ => true), Is.False); - Assert.That(CultDocumentRegistry.Owns(narrow.SchemaId, writes, declares, new CatalogEntryLookup(writes), entry => entry, _ => true), Is.True); + Assert.That(CultDocumentRegistry.Owns("tests.restamp.unpublished", writes, declares, new CatalogEntryLookup(writes, null), entry => entry, _ => true), Is.False); + Assert.That(CultDocumentRegistry.Owns(narrow.SchemaId, writes, declares, new CatalogEntryLookup(writes, null), entry => entry, _ => true), Is.True); } private static string VectorPath(string name) diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 68eba500..f7da30e2 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -13,7 +13,7 @@ Releases as 3.0.0 (major). - A store is written in the format its content needs, and CultLib 2.0 refuses the newer ones by name. A single-file store holding a document variant is written as `cultcache.store.v2`, one holding an element id as `cultcache.store.v3`; a directory store holding an element id is written as `cultcache.store.v5` (`v4` otherwise). A store is marked only when a record actually holds an id or a variant. Upgrade readers before writers: a 2.0 reader that opens such a store is refused, not misread. - Writes are write sets. A flush or commit replaces the records it staged and copies every other record forward as stored, under the store's lock, instead of rewriting the whole store from the cache's memory. A record the cache does not know is kept, and an untouched record keeps a dropped member's slot through every write (never reuse a dropped slot; see the schema-compatibility contract). - A write or removal at a key whose stored record the writer does not own is refused with `CultSchemaConflictException`, and the file is left as it was. A writer owns a record whose schema id it declares (owns or lists compatible) and a record its type reads whole under compatible drift: that record is writable and is restamped under the type's own id on write, as in 2.0.1. A record a type reads only by dropping persisted slots stays read-only until its id is declared compatible, which accepts the loss (this differs from 2.0.1, which shed the slots silently). A cache owns through its registry; a cacheless CultMesh write owns through the catalog entry it writes. -- A cacheless `WriteDocument` or `WriteRecord` picks the type that reads a stored record the way a cache registering the same types does (`WriteDocument` with no registry lends `CultDocumentRegistry.Shared`; `WriteRecord` without one takes the entry it writes), and owns the record only when that type is the entry it writes and reads it whole. Refused: a renamed entry named like the written schema but listing another registered type's id is refused as a cache refuses it; and a record another registered type claims, under that type's current id or an old id it lists, is refused and left in place. Also refused: a record under an id nobody registered whose stored entry lists another registered type's id ahead of the written one's, and a `WriteRecord` record under an id the passed entry does not list when a lent registry's entry for the same schema id does (the lent registry never widens what the passed entry declares). As in 2.0.1, a record the written entry's own type reads whole under drift is owned and restamped, but only when no other registered type is picked first: a lent registry type or a `Shared` type that lists the record's id, or whose id the stored entry lists first, takes it and the write is refused. Because a registry-less `WriteDocument` reads `Shared`, any type loaded there can do this, not only a second type with the written schema's name (that case is ambiguous and refused); a wider registry can only refuse more, never own more. Pass a registry to pin the answer. +- A cacheless `WriteDocument` or `WriteRecord` picks the type that reads a stored record the way a cache registering the call's registry would, except that the entry it writes is the only description of its schema id: `WriteDocument` takes it from the registry (`CultDocumentRegistry.Shared` when none is passed), `WriteRecord` writes the entry it is given, and without a registry that entry is the only claimant. A record is owned when the written entry declares its id (its own or one it lists compatible), or when the picked type is the written entry and reads the record whole. Refused, with the file left as it was: a record another registered type claims, under that type's current id or an old id it lists; a stored entry named like the written schema that lists another registered type's id; and a record two registered versions of the written schema could read. As in 2.0.1, a record the written entry's own type reads whole under drift is owned and restamped. A registry only adds refusals: it never changes what the written entry declares, so a registry whose type shares the written schema id neither widens nor narrows the passed entry. A registry-less `WriteDocument` reads `Shared`, so any type loaded there can refuse the write; pass a registry to pin the answer. - Registration refuses an owned object-list element type that has no `[CultElementId]` member (a public string with `[Key]`), naming the type and the list. Element ids let a write set merge a list by identity; a list of record references or plain values is a whole value and is not affected. - The abstract and protected surface for store kinds outside this library changed. On `SingleFileBackingStore`, `SerializeSnapshot` and `DeserializeSnapshot` are replaced by the abstract `SerializeStore`, `SerializeRecord`, `SerializeCatalogEntry` and `ReadStore`, and the abstract `SerializePayload(object)` becomes `SerializePayload(object document, Type documentType)`: a store encodes a document as the type it is given. On `CacheBackingStore`, `Judging` is removed, `ToStoredDocument` takes the record's version (`record, version, catalog, deserialize, out report`), and these protected members are new for a store kind to implement or call: `HeaderFor`, `HeaderForWrite`, `Foreign`, `SetForeignRecords`, `Undeclared`, `RefuseUndeclared`, `RefuseUndeclaredOnDisk`, `RefuseRemovalOfUndeclared`, `Refuse`, `SameStoredIdentity`, `VersionOf`, `StoredAtOver`, `Overwrites`, `StagedChecks`, `StageChecks` and `JudgeChecks`. `MarkFlushSucceeded` is unchanged. `CacheBackingStore.ForeignRecords` is new and public: the records a store holds under a schema id its cache does not declare. - `CultCommitRequest.Expected` is now `IReadOnlyList<(CultRecordKey Key, string? Version)>` (it was a key with a schema id and a stored-at), `CultCommitRequest.Checks` is new, and `ConditionsHold` takes `(IReadOnlyDictionary durable, IEnumerable observed, IEnumerable foreign)` in place of `(IReadOnlyCollection, IEnumerable)`. A store kind outside the library lists the record versions it holds and passes its foreign records.