From 04494b2edbc2e7af63c53429e7e4bd99a7b4f91e Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 00:31:48 +0000 Subject: [PATCH] CultCache: one owner decides whether a record is writable; a flatten through an unowned base is refused WriteRefusal holds Claim's whole decision (no home store, read-only home, the home store's Refusal) and the flatten-chain walk: a flatten is refused with the refusal of the first record on its base chain its home store does not own. Claim throws its answer; IsWritable asks it of the held record's removal form, and for an unheld key whether no store lists it foreign. IsWritable's per-store Refusal sweep is deleted (findings iswritable-readonly-store, flatten-marked-base-loss). MintElementIds' doc names the mark. Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Caching/CultCache.cs | 80 +++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index ebea77d4..b386be0c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2308,8 +2308,9 @@ namespace GameCult.Caching // The one-shot rewrite for stores written before element ids existed: upserts every record whose ids exist only in // memory (minted when it loaded), through the normal write path, one commit per home store. Returns how many records - // it rewrote; a second call finds none. A selected record that is neither declared nor read whole is read-only, - // so its home store's commit is refused with CultSchemaConflictException; commits to earlier stores stand. + // it rewrote; a second call finds none. A selected record that is not owned (neither declared nor read whole, or held + // read-only for slots past this build's, see CultStoredDocument.UnreadSlots) is read-only, so its home store's commit is + // refused with CultSchemaConflictException until a build that reads those slots rewrites it; commits to earlier stores stand. public int MintElementIds() { var groups = Held(() => _entries.Values @@ -2665,14 +2666,19 @@ namespace GameCult.Caching } /// - /// Whether a write or removal of the key would be let through by every attached store: false when a store lists the key foreign, - /// or holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's, see - /// ); true for a key no store holds. Live and advisory: the write still decides. + /// Whether a removal of the key, or a write of the record's own type, would be let through: the write's own decision, asked of + /// the record the cache holds. False when no attached store is home to the record's type, when its home store is read-only, or + /// when that store holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's, + /// see ). For a key the cache does not hold, false only when a store lists it + /// foreign. One write it does not predict: a Flatten of a variant whose base chain holds an unowned record is refused, though + /// the variant itself is writable. Live and advisory: the write still decides. /// public bool IsWritable(CultRecordKey key) { lock (Gate()) - return _stores.All(entry => entry.Store.Refusal(key.Value, null) == null); + return _entries.TryGetValue(key.Value, out var held) + ? WriteRefusal(held, upsert: false, null, out _) == null + : _stores.All(entry => entry.Store.ForeignRecords.All(record => !string.Equals(record.Key, key.Value, StringComparison.Ordinal))); } public object? Get(CultRecordKey key) @@ -3170,6 +3176,8 @@ namespace GameCult.Caching IReadOnlyCollection? removed) { var removedKeys = removed?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal); + var batch = new Dictionary(StringComparer.Ordinal); + foreach (var stored in admitted) batch[stored.Key.Value] = stored; var homes = new List(); var globals = new Dictionary(_globals); var evictedKeys = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal); @@ -3218,23 +3226,57 @@ namespace GameCult.Caching void Claim(CultStoredDocument stored, bool upsert) { - var home = Home(stored.Descriptor.DocumentType); - if (home == null) + if (WriteRefusal(stored, upsert, batch, out var home) is { } refusal) throw refusal; + if (home != null) homes.Add(home); + } + } + + // The one answer to whether a record may be written (upsert: a write of its type) or its key removed, before anything is staged. + // Claim throws it; IsWritable asks it of the held record's removal form. Null when the write may land; home is where it lands, + // null for a store-less cache. batch holds the records the same batch admits, by key. + private Exception? WriteRefusal(CultStoredDocument stored, bool upsert, IReadOnlyDictionary? batch, out CacheBackingStore? home) + { + home = Home(stored.Descriptor.DocumentType); + if (home == null) + { + if (_stores.Count > 0) + return new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}."); + // No store, no file to judge, but the same rule: a record is replaced only by its own type's write. + return upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor) + ? CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor) + : null; + } + + if (home.IsReadOnly) + return new InvalidOperationException( + $"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}."); + return home.Refusal(stored.Key.Value, upsert ? stored.Descriptor : null) + ?? (upsert && stored.Flattens ? FlattenRefusal(stored, batch) : null); + } + + // A flatten writes its base chain's decoded view into a plain record, so it is refused when a record on that chain is not owned + // by its home store (the slots or drift that record's decode dropped would be lost): that ancestor's own refusal. The chain starts + // at the staged delta's base when the variant is flattened as it lands, else at the held variant's; a key the batch admits is + // judged by its own claim, and the walk follows its staged form. + private CultSchemaConflictException? FlattenRefusal(CultStoredDocument flatten, IReadOnlyDictionary? batch) + { + var delta = flatten.Variant ?? (_entries.TryGetValue(flatten.Key.Value, out var variant) ? variant.Variant : null); + var walked = new HashSet(StringComparer.Ordinal) { flatten.Key.Value }; + for (var key = delta?.BaseKey; key != null && walked.Add(key);) + { + CultStoredDocument? ancestor; + if (batch == null || !batch.TryGetValue(key, out ancestor)) { - if (_stores.Count > 0) - throw new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}."); - // No store, no file to judge, but the same rule: a record is replaced only by its own type's write. - if (upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor)) - throw CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor); - return; + if (!_entries.TryGetValue(key, out ancestor)) + return null; + if (Home(ancestor.Descriptor.DocumentType)?.Refusal(key, null) is { } refusal) + return refusal; } - if (home.IsReadOnly) - throw new InvalidOperationException( - $"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}."); - home.RefuseUndeclared(stored.Key.Value, upsert ? stored.Descriptor : null); - homes.Add(home); + key = ancestor.Variant?.BaseKey; } + + return null; } private sealed class VariantPlan