From 28c9a033884960839a1aac943b96ea3c0898e862 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 13:11:24 +0200 Subject: [PATCH] Removing a foreign record is refused typed, not reported as done Remove and a committed batch removal of a key the cache does not hold ask every store whether it carries a foreign record there (RefuseUndeclared); one that does refuses with the typed schema conflict instead of Remove returning false and Commit returning Committed over a record the store still carries. Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Caching/CultCache.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 2abf8337..fa586e73 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2352,11 +2352,15 @@ namespace GameCult.Caching return key; })); - // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. + // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares, + // read-only or foreign. False when the cache holds nothing there and no store carries a foreign record there. public bool Remove(CultRecordKey key) => Held(() => { if (!_entries.TryGetValue(key.Value, out var existing)) + { + RefuseRemovalOfForeign(key.Value); return false; + } Admit(Array.Empty(), new[] { existing }, null, (home, _) => { home?.Delete(existing); @@ -2627,6 +2631,8 @@ namespace GameCult.Caching : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant) : StampFlatten(op.Key)) .ToArray(); + foreach (var op in batch.Operations.Values.Where(op => op.IsRemoval && !_entries.ContainsKey(op.Key.Value))) + RefuseRemovalOfForeign(op.Key.Value); var deletes = batch.Operations.Values .Where(op => op.IsRemoval && _entries.ContainsKey(op.Key.Value)) .Select(op => _entries[op.Key.Value]) @@ -2650,6 +2656,14 @@ namespace GameCult.Caching }); } + // A removal of a key the cache does not hold removes nothing, so no store sees it; but a store that carries a foreign record + // at the key refuses it, as it refuses every write of that record, rather than let the caller believe it was removed. + private void RefuseRemovalOfForeign(string key) + { + foreach (var (store, _) in _stores) + store.RefuseUndeclared(key); + } + // Every add and remove passes here: single writes, committed batches, and loads (source set). // Nothing lands unless all of it is admissible; land is the durable step between validation and memory. private CultCommitOutcome Admit(