From 1afbaed77523ba5037967beee7229baeb4fe7918 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 19:15:22 +0200 Subject: [PATCH] R-R fixup: v0's empty-answers-empty must not reach the v1 door The first attempt at R-R built a CultNetSelection with an empty (non-null) Keys/Schemas array straight from the lowered v0 filter and handed it to the shared evaluator. CultNetSelectionValidation.ValidateShape refuses exactly that shape ("selection.keys is present and empty"), because for the v1 typed door an empty list is a caller mistake. v0 and v1 read the same emptiness differently on purpose (v0: answer nothing; v1: refuse), so v0 must decide its answer before the selection ever reaches Validate. CultNetDocumentRegistry.CreateRawSnapshotResponse and CultNetDatabase.CreateShardSnapshotResponse now short-circuit to an empty document set when the lowered schemas or keys list is present-and-empty, without constructing a CultNetSelection or calling into the evaluator at all. Caught by the test added in the previous commit (CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty), which failed with CultNetSelectionInvalidException before this fix. Verified on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release, all green (217 passed, 2 skipped by design). Co-Authored-By: Claude Sonnet 5 --- src/GameCult.Networking/CultNetDatabase.cs | 21 +++++++++++++++++-- .../CultNetDocumentRegistry.cs | 17 ++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 9ebe304f..d5b3773d 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -614,10 +614,27 @@ namespace GameCult.Networking ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); + var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds); + var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); + + // R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not + // reach the v1 door, which refuses an empty selection.schemas/keys outright. + if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 }) + { + return new CultNetSnapshotResponseRawMessage + { + MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId, + Documents = Array.Empty(), + ShardId = shard.ShardId, + ShardEpoch = shard.Epoch, + ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId) + }; + } + var selection = new CultNetSelection { - Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds), - Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys), + Schemas = lowSchemas, + Keys = lowKeys, Projection = CultNetSelectionProjections.Document }; bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) => diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index de275adf..6d2c778a 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -283,10 +283,25 @@ namespace GameCult.Networking CultNetDocumentMessageOptions? options = null) { if (cache == null) throw new ArgumentNullException(nameof(cache)); + var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds); var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys); + + // R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not + // reach the v1 door, which refuses an empty selection.schemas/keys outright + // (CultNetSelectionValidation.ValidateShape). v0 and v1 disagree about what an empty list + // means, so v0 decides this before the v1 evaluator ever sees the selection. + if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 }) + { + return new CultNetSnapshotResponseRawMessage + { + MessageId = RequireNonEmpty(messageId, nameof(messageId)), + Documents = Array.Empty() + }; + } + var selection = new CultNetSelection { - Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds), + Schemas = lowSchemas, Keys = lowKeys, Projection = CultNetSelectionProjections.Document };