diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs
index 68b9cb49..58aede9d 100644
--- a/src/GameCult.Mesh/CultMeshSnapshots.cs
+++ b/src/GameCult.Mesh/CultMeshSnapshots.cs
@@ -857,9 +857,11 @@ namespace GameCult.Mesh
///
/// Resolves one selection for a typed convenience call: explicit
- /// win; otherwise an explicit, non-empty means no schema filter;
- /// otherwise the selection defaults to the document type's own schema. The one owner of that
- /// rule (docs/cultnet-selection-cut.md, section 4).
+ /// win; otherwise an explicit - including one that lowers to an
+ /// empty array - means no schema filter; otherwise the selection defaults to the document
+ /// type's own schema. R-R (2026-09-22): an empty is a real filter
+ /// (answers nothing), not "no filter", so it must not fall through to the schema default
+ /// either. The one owner of that rule (docs/cultnet-selection-cut.md, section 4).
///
internal static CultNetSelection ResolveDefaultSelection(
IReadOnlyList? schemaIds,
diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs
index 84871469..de275adf 100644
--- a/src/GameCult.Networking/CultNetDocumentRegistry.cs
+++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs
@@ -283,14 +283,32 @@ namespace GameCult.Networking
CultNetDocumentMessageOptions? options = null)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
+ var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
var selection = new CultNetSelection
{
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
- Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys),
+ Keys = lowKeys,
Projection = CultNetSelectionProjections.Document
};
- var page = SelectAll(cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options);
+ // R-R: v0's pre-cut CreateRawSnapshotResponse (b3d9cf7) iterated the requested record keys
+ // in the order the caller sent them (a HashSet built from the array, enumerated in
+ // insertion order). The evaluator's own tiebreak (schema then key) only applies when the
+ // caller did not name keys.
+ Func ordinalOf;
+ if (lowKeys is { Length: > 0 })
+ {
+ var order = new Dictionary(StringComparer.Ordinal);
+ for (var i = 0; i < lowKeys.Length; i++)
+ order.TryAdd(lowKeys[i], i);
+ ordinalOf = (_, key) => order.TryGetValue(key.Value, out var index) ? index : lowKeys.Length;
+ }
+ else
+ {
+ ordinalOf = static (_, _) => 0;
+ }
+
+ var page = SelectAll(cache, selection, ordinalOf, asOf: 0, options);
return new CultNetSnapshotResponseRawMessage
{
diff --git a/src/GameCult.Networking/CultNetSelection.cs b/src/GameCult.Networking/CultNetSelection.cs
index 2ae88305..ed4ad9b0 100644
--- a/src/GameCult.Networking/CultNetSelection.cs
+++ b/src/GameCult.Networking/CultNetSelection.cs
@@ -210,17 +210,26 @@ namespace GameCult.Networking
///
public static class CultNetV0SelectionLowering
{
- /// Lowers one v0 filter list; see the type summary.
+ ///
+ /// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a null list means
+ /// "no filter" and stays null; a non-null list, even one that filters down to nothing,
+ /// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was
+ /// wrong - the pre-cut reference (CultNetDatabase.CreateShardSnapshotResponse at
+ /// b3d9cf7) tests filter?.RecordKeys != null, not a length, so
+ /// recordKeys: [] always answered empty. The evaluator already honours that distinction
+ /// (CultNetSelectionEvaluator: a non-null Keys array excludes every row when it
+ /// is empty); only this lowering used to erase it before the selection ever reached the
+ /// evaluator.
+ ///
public static string[]? Lower(IReadOnlyList? values)
{
- if (values is not { Count: > 0 })
+ if (values == null)
return null;
- var filtered = values
+ return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
- return filtered.Length == 0 ? null : filtered;
}
}
diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
index f829b369..906f564f 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
@@ -2746,9 +2746,8 @@ public sealed class CultMeshStreamingTests
(await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key");
- // MESH-KEY-Loosening (docs/cultnet-selection-cut.md, section 4/S2-4): the recordKey match is
- // exact (ordinal), not case-insensitive. A case-variant key must not win even when it appears
- // first in the response.
+ // The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not
+ // win even when it appears first in the response.
var caseVariantKey = key.Value.ToUpperInvariant();
var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot(
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
@@ -2794,13 +2793,14 @@ public sealed class CultMeshStreamingTests
(await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key");
}
- // MESH-DEFAULT-Loosening (docs/cultnet-selection-cut.md, section 4/S2-7): ResolveDefaultSelection's
- // no-schema-filter branch must trigger on a non-empty recordKeys, not merely a non-null one. An
- // explicit empty recordKeys array is a v0-compatible list that lowers to null (rule 1's v0
- // lowering), so it does not suppress the type's own schema default - the wire must carry
- // [T.SchemaId], not schemas=null.
+ // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22) corrects the earlier commit-2
+ // ruling this test used to pin: an explicit empty recordKeys array is a real filter, not "no
+ // filter". ResolveDefaultSelection must not fall back to the type's own schema default just
+ // because the caller's recordKeys happened to be empty - the wire must carry recordKeys=[] and
+ // no schema default, and the answer is empty, exactly as v0 answered before this cut
+ // (CultNetDatabase.CreateShardSnapshotResponse at b3d9cf7 tests RecordKeys != null, not a length).
[Test]
- public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema()
+ public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_AnswersEmpty()
{
var cache = new CultCache();
var key = new CultRecordKey("mesh-note:empty-keys-default");
@@ -2828,11 +2828,11 @@ public sealed class CultMeshStreamingTests
var documents = await session.FetchDocumentsAsync(recordKeys: Array.Empty());
- documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default");
+ documents.Should().BeEmpty();
requests.Should().ContainSingle();
- requests[0].SchemaIds.Should().ContainSingle().Which
- .Should().Be(CultDocumentRegistry.Shared.GetRequired().SchemaId);
- requests[0].RecordKeys.Should().BeNull();
+ requests[0].SchemaIds.Should().BeNull();
+ requests[0].RecordKeys.Should().NotBeNull();
+ requests[0].RecordKeys.Should().BeEmpty();
}
[Test]
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 7784b02c..6d28c2b7 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -3284,6 +3284,63 @@ namespace GameCult.Networking.Tests
Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload));
}
+ // R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's
+ // pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter".
+ [Test]
+ public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty()
+ {
+ var cache = new CultCache();
+ var registry = new CultNetDocumentRegistry()
+ .Register(CultNetDocumentBinding.ForDocument(
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+ await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" });
+
+ var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty());
+ var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request);
+
+ Assert.That(response.Documents, Is.Empty);
+ }
+
+ // R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order
+ // the caller sent them, not in schema-and-key sort order. This pins that order survives the
+ // move to the shared evaluator.
+ [Test]
+ public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder()
+ {
+ var cache = new CultCache();
+ var registry = new CultNetDocumentRegistry()
+ .Register(CultNetDocumentBinding.ForDocument(
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+
+ var players = new[] { "zeta", "alpha", "mid" }
+ .Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name })
+ .ToArray();
+ var handles = new List>();
+ foreach (var player in players)
+ handles.Add(await cache.AddAsync(player));
+
+ // Request in an order that matches neither the insertion order nor the ordinal-sorted
+ // record-key order, so a schema-and-key sort would visibly reorder the response.
+ var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value };
+ var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder);
+ var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request);
+
+ Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder));
+ }
+
+ [Test]
+ public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty()
+ {
+ Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null);
+ Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Not.Null);
+ Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty()), Is.Empty);
+ Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null);
+ Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty);
+ Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" }));
+ }
+
[Test]
public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard()
{