From f744817a155f7bba1aa5be2176aeda9a24efbdab Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 27 Oct 2026 09:18:30 +0100 Subject: [PATCH] Caching: a lent registry never widens what the passed entry declares; docs state every refusal The cacheless written test now holds on the entry passed: the claimant's compatible ids must all be listed by the written entry of its id. CHANGELOG and WriteRecord's registry doc describe the refusal through another registered type's listed id, the condition on own drift, and the Shared caveat. Not yet built or tested here. Co-Authored-By: Claude Sonnet 5.5 --- src/GameCult.Caching/CultCache.cs | 5 ++- .../RestampOnWriteTests.cs | 45 +++++++++++++++++++ unity/org.gamecult.cultlib/CHANGELOG.md | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 4a3ba886..dee7e227 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -4487,7 +4487,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; another registered type's record is refused. It serves this call only: the store keeps no registry. + /// 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 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. @@ -4732,7 +4732,8 @@ namespace GameCult.Caching 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)); + 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); diff --git a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs index 6c5aa3d5..35bdb405 100644 --- a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs +++ b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs @@ -556,6 +556,51 @@ 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) + { + 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 record in snapshot.Records) + record.SchemaId = ListerOldId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteLister) }); + var full = registry.GetRequired().ToCatalogEntry(); + var passed = new CultSchemaCatalogEntry + { + SchemaId = full.SchemaId, + ContentHash = full.ContentHash, + SchemaName = full.SchemaName, + SchemaVersion = full.SchemaVersion, + CompatibleSchemaIds = entryListsOldId ? full.CompatibleSchemaIds : new[] { full.SchemaId }, + 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 }), + }; + var before = Fingerprint(path); + + if (entryListsOldId) + { + new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed, null, registry); + Assert.That(Snapshot(path).Records.Single(r => r.Key == "note").SchemaId, Is.EqualTo(full.SchemaId)); + return; + } + Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed, null, registry)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(record, passed)); + } + // The registry's other types change nothing about a record the written type claims itself. [Test] public void AnotherRegisteredTypeDoesNotTakeAWritersOwnDriftRecord() diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index eeaa4b5f..68eba500 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. Unchanged from 2.0.1 and the earlier builds of this release: a record the written entry's own type reads whole under drift is owned and restamped. Because a registry-less `WriteDocument` reads `Shared`, loading a second type with the written schema's name makes the claim ambiguous and the write is refused (it 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 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. - 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.