From 568e8e356fa056e860f519e5f77c6410bc7be570 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 06:12:49 +0200 Subject: [PATCH] CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22): empty/blank schemas-keys still reached the evaluator, the shard snapshot ran its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only selection terms, the Mesh one-pass read could return the wrong schema's record at a shared key, two decode rules for a foreign schema id disagreed, dead identity-comparison code from the _projectRecord removal was still live, and a disposed-socket race could abort a mutation run instead of recording NO VERDICT. This is Self's fix batch for all of it. - CultNetSelection.Validate now refuses a blank/whitespace entry inside schemas or keys, not just an empty array. CultNetSelectionEvaluator no longer treats [] as "no filter" (the door already refuses it before evaluation). CultNetV0SelectionLowering is the one place that lowers an empty or all-blank v0 list to null; CreateRawSnapshotResponse and CreateShardSnapshotResponse both route through it, and CultMesh's own Clean() now delegates to it instead of duplicating the rule. - CreateShardSnapshotResponse no longer carries a private loop or its own schema/key matching: it builds a CultNetSelection and answers through CultNetDocumentRegistry.SelectPage (the same evaluator path as the non-shard snapshot), with the shard's own membership check as a row filter. Binding-alias expansion now runs on both paths identically. - ResolveDefaultSelection decides "no schema filter" from the cleaned recordKeys, not the raw array length. OverlaySelection stops deciding what a missing override means: any explicit schemaIds/recordKeys override replaces the default selection wholesale; no override keeps the default untouched. - CultMeshSnapshotRequestOptions.Selection is now checked by EnsureV0Compatible before every send: fields, cites, cited, limit, cursor, descending, and any non-default projection are refused typed (CultNetSelectionInvalidException), never silently dropped from the v0 wire message. Mesh sending v1 is FU-Mesh-v1, not this cut. - CultMeshHotBodySubscription.Selection returns a defensive copy on every read so its arrays can't be mutated out from under the stored selection. - ReadDocumentFromSnapshotResponse now searches candidates at one recordKey in one order - exact schema, then alias, then a foreign schema id decoded from the payload - instead of one OR'd condition where a case-insensitive or structurally-lucky candidate could win first. The foreign-id decode reads the payload's embedded schema version with the registry's shared CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against the caller's own descriptor directly, rather than resolving to "whichever registered type owns this schema string" - that resolution is ambiguous when two document types alias the same schema id (MeshNoteDocument and MeshNoteAliasDocument in the existing fixtures), which is what broke on the first pass; TryResolveDescriptorByPayloadSchema stays for its existing caller (ResolveDescriptorForRawDocument) with a note on when not to use it. DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in favour of the shared one. - CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key throw in CreateProjectedSnapshot, are deleted - they can no longer fire now that a projection's dictionary key is always the record's own key. A new test flips authorizeRecord per key while the whole request stays authorized (so DemandActive never toggles) and proves both halves of the remaining diff: the removal of a now-unauthorized key and the add loop picking up a newly-authorized one. - Every NetworkingTests.cs test that starts an IsBackground poll thread now joins it before its server disposes (about 20 tests; one already did). - scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no longer throws the whole run away under PowerShell 5.1's $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in a terminating NativeCommandError); Run-Tests catches it and the entry loop records NO VERDICT instead of aborting every remaining entry. - Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard snapshot paths agree, including shard-membership exclusion of a document outside the shard's key prefix and a binding schema-id override matching on both paths. - Mutation entries updated/added to match: the stale NET-D4-FourthMatcher entry (its anchor was the private loop this batch deleted, not refactored) is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's header miscount ("two of three" loosening entries when there are only two) is corrected and both previously-surviving loosening mutants (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures; scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering Reconcile's remaining two mutants. Co-Authored-By: Claude Sonnet 5 --- ...te-cultnet-selection-fixbatch2.entries.ps1 | 33 +++ .../mutate-cultnet-selection-mesh.entries.ps1 | 44 ++-- ...e-cultnet-selection-networking.entries.ps1 | 27 +-- scripts/mutate-dotnet.ps1 | 33 ++- src/GameCult.Mesh/CultMesh.cs | 54 +++-- src/GameCult.Mesh/CultMeshBodyDemand.cs | 17 +- src/GameCult.Mesh/CultMeshSnapshots.cs | 108 +++++----- src/GameCult.Networking/CultNetDatabase.cs | 40 ++-- .../CultNetDatabaseSubscriptionServer.cs | 28 +-- .../CultNetDocumentRegistry.cs | 41 +++- src/GameCult.Networking/CultNetSelection.cs | 41 ++++ .../CultNetSelectionEvaluator.cs | 8 +- .../CultMeshStreamingTests.cs | 88 ++++++++ .../NetworkingTests.cs | 198 ++++++++++++++++++ 14 files changed, 586 insertions(+), 174 deletions(-) create mode 100644 scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 diff --git a/scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 b/scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 new file mode 100644 index 00000000..a8d322b2 --- /dev/null +++ b/scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 @@ -0,0 +1,33 @@ +# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2 fix batch +# (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22). Targets the dead-identity-code deletion +# in src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs (S2-2): Reconcile must still walk +# its diff when the request stays authorized (not gated on DemandActive changing), and its add loop +# must still fire. Test project: tests/GameCult.Networking.Tests. + +@( + @{ + Id = 'NET-RECONCILE-EarlyReturn-Loosening' + Rule = 'Reconcile walks the delivered/added diff whenever it runs; it must not early-return merely because request-level authorization (and so DemandActive) has not changed - per-record authorization can still have moved.' + Mutant = 'loosening' + File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority' + Old = 'if (!_projections.TryGetValue(key, out var projection)) return; + var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;' + New = 'if (!_projections.TryGetValue(key, out var projection)) return; + var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false; + if (projection.DemandActive == authorized) return;' + }, + @{ + Id = 'NET-RECONCILE-AddLoop-Revert' + Rule = 'Reconcile''s add loop sends an upsert (added: true) for every key in the new snapshot that was not already delivered.' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority' + Old = 'foreach (var current in next.BySourceRecordKey) + { + if (!projection.DeliveredBySourceRecordKey.ContainsKey(current.Key)) + SendUpsert(key.Peer, key.Id, current.Value, added: true); + }' + New = '' + } +) diff --git a/scripts/mutate-cultnet-selection-mesh.entries.ps1 b/scripts/mutate-cultnet-selection-mesh.entries.ps1 index 7f32360f..765b23ee 100644 --- a/scripts/mutate-cultnet-selection-mesh.entries.ps1 +++ b/scripts/mutate-cultnet-selection-mesh.entries.ps1 @@ -1,17 +1,19 @@ -# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2d - the CultMesh -# collapse (docs/cultnet-selection-cut.md, sections 4/7/11). Targets: src/GameCult.Mesh/CultMesh.cs +# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2 fix batch +# (docs/cultnet-selection-cut.md, sections 4/7/11). Targets: src/GameCult.Mesh/CultMesh.cs # (ReadDocumentFromSnapshotResponse's one exact read) and src/GameCult.Mesh/CultMeshSnapshots.cs # (ResolveDefaultSelection's "no schema filter when keys are given" rule). Test project: # tests/GameCult.Mesh.Tests. # # Every entry below carries its own File, so this runs as one invocation with both -Target paths. # -# Two of the three loosening entries here SURVIVED against the current fixtures (recorded, not -# swapped for an easier mutant): MESH-DEFAULT-Loosening and MESH-KEY-Loosening. Both are genuine -# weaker readings of the rule they sit beside; no test in tests/GameCult.Mesh.Tests exercises an -# explicit empty (non-null) recordKeys array or a record-key case variation, so nothing currently -# distinguishes the loosened behavior from the correct one. That is a fixture gap in -# CultMeshStreamingTests.cs, not evidence the mutant is unreachable in principle. +# There are two loosening entries here (MESH-DEFAULT-Loosening and MESH-KEY-Loosening); the header +# above this line previously miscounted them as "two of three" (S2-9/S2-10). Both were genuine +# weaker readings of the rule they sit beside and both SURVIVED against the pre-fix-batch fixtures, +# because nothing in tests/GameCult.Mesh.Tests exercised an explicit empty (non-null) recordKeys +# array or a record-key case variation. CultMeshStreamingTests.cs now carries +# FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema (MESH-DEFAULT) and the +# case-variant-key block appended to DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments +# (MESH-KEY); both mutants below now die. @( @{ @@ -20,18 +22,20 @@ Mutant = 'revert' File = 'src/GameCult.Mesh/CultMeshSnapshots.cs' Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases' - Old = 'var schemas = schemaIds != null + Old = 'var cleanedKeys = Clean(recordKeys); + var schemas = schemaIds != null ? Clean(schemaIds) - : (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId });' - New = 'var schemas = schemaIds != null ? Clean(schemaIds) : new[] { descriptor.SchemaId };' + : (cleanedKeys != null ? null : new[] { descriptor.SchemaId });' + New = 'var cleanedKeys = Clean(recordKeys); + var schemas = schemaIds != null ? Clean(schemaIds) : new[] { descriptor.SchemaId };' }, @{ Id = 'MESH-DEFAULT-Loosening' - Rule = 'ResolveDefaultSelection: the no-schema-filter branch triggers on a non-empty recordKeys, not merely a non-null one.' + Rule = 'ResolveDefaultSelection: the no-schema-filter branch triggers on a non-empty (post-cleaning) recordKeys, not merely a non-null one - an explicit empty recordKeys array still defaults to the document type''s own schema (docs/cultnet-selection-cut.md, S2-7/rule 1''s v0 lowering).' Mutant = 'loosening' File = 'src/GameCult.Mesh/CultMeshSnapshots.cs' - Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases' - Old = 'recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId }' + Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema' + Old = 'cleanedKeys != null ? null : new[] { descriptor.SchemaId }' New = 'recordKeys != null ? null : new[] { descriptor.SchemaId }' }, @{ @@ -55,14 +59,14 @@ }, @{ Id = 'MESH-FOREIGN-Revert' - Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload decodes as TDocument.' + Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared TryReadSchemaVersion) alias-matches the caller''s own descriptor.' Mutant = 'revert' File = 'src/GameCult.Mesh/CultMesh.cs' Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments' - Old = 'if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) || - CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor) || - TryDecodeAsMessagePack(candidate, out _))' - New = 'if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) || - CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor))' + Old = 'else if (byPayload == null && + CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion && + CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor)) + byPayload = candidate;' + New = '' } ) diff --git a/scripts/mutate-cultnet-selection-networking.entries.ps1 b/scripts/mutate-cultnet-selection-networking.entries.ps1 index b8ddb8e1..75c0da09 100644 --- a/scripts/mutate-cultnet-selection-networking.entries.ps1 +++ b/scripts/mutate-cultnet-selection-networking.entries.ps1 @@ -93,21 +93,24 @@ New = 'return;' }, @{ - Id = 'NET-D4-FourthMatcher-Revert' - Rule = 'D4/Self''s ruling 2026-09-22: CreateShardSnapshotResponse''s schema filter is the one alias matcher (CultNetSchemaAliasMatching.MatchesAny), not a private fourth copy.' + # S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, commit 2 fix batch): + # CreateShardSnapshotResponse no longer carries its own schema/key filter at all - it went + # through CultNetSelectionEvaluator.SelectPage, with the shard's own row filter as the only + # thing this method still owns. NET-D4-FourthMatcher-Revert's original anchor + # ("requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(...)") was the private + # loop this fix batch deleted, not merely refactored; the schema-matching mutants it proved are + # now covered by this file's CultNetSelectionEvaluator.cs entries (NET-S1/S2 etc.) plus + # tests/GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_ShardAndNonShardSnapshot_*. + # This entry now proves the one thing CreateShardSnapshotResponse still owns: the shard + # membership check. + Id = 'NET-D4-ShardMembership-Revert' + Rule = 'CreateShardSnapshotResponse''s row filter still enforces shard membership (shard.Matches), even though schema/key filtering is now entirely the evaluator''s.' Mutant = 'revert' File = 'src/GameCult.Networking/CultNetDatabase.cs' - Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias' - Old = '(requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(requestedSchemaIds, descriptor)) ||' - New = '(requestedSchemaIds != null) ||' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest' + Old = '!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key);' + New = '!string.IsNullOrWhiteSpace(key.Value);' } - # A loosening mutant here ('(requestedSchemaIds != null && requestedSchemaIds.Length == 0) ||', - # i.e. reject only an empty-but-non-null list rather than "no requested id matches") was run by - # hand against CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias and - # SURVIVED: the fixture puts exactly one document in the whole cache, so an over-permissive - # filter still returns the same single row. This is a finding about the fixture, not a defended - # rule - a second document of a different schema in the same shard is needed to make the - # loosening fail, and is not added here (no easier mutant substituted per the Hands brief). # # Q-J (docs/cultnet-selection-cut.md section 2 "Numbers", 2026-09-22): the door's canonical-decimal # grammar (CultNetSelection.cs) and the comparator that reads it (CultNetSelectionEvaluator.cs's diff --git a/scripts/mutate-dotnet.ps1 b/scripts/mutate-dotnet.ps1 index 87c7f9cc..d3201b8a 100644 --- a/scripts/mutate-dotnet.ps1 +++ b/scripts/mutate-dotnet.ps1 @@ -58,7 +58,25 @@ function Get-SidecarPath([string]$path) { "$path.mutation-original" } function Run-Tests([string]$filter) { $args = @('test', $testProjectPath, '-c', 'Debug', '--nologo') if ($filter) { $args += @('--filter', $filter) } - $output = & dotnet @args 2>&1 | Out-String + # A test-host crash (e.g. S2-1's disposed-socket race on a background poll thread) can write to + # stderr; under PowerShell 5.1, `2>&1` on a native command wraps each stderr line in a + # NativeCommandError, and with the script's own $ErrorActionPreference = 'Stop' that becomes a + # terminating error that would otherwise throw the whole run away. Run this one call under + # 'Continue' and catch anything that still throws, so a host crash is captured as output and + # classified as NO VERDICT by the caller instead of aborting every remaining entry. + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $crashed = $false + try { + $output = & dotnet @args 2>&1 | Out-String + } + catch { + $output = "$_" + $crashed = $true + } + finally { + $ErrorActionPreference = $previousPreference + } Write-Host $output $passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)') $failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)') @@ -67,7 +85,8 @@ function Run-Tests([string]$filter) { $failedLine = $output -match 'Failed!' [pscustomobject]@{ Compiled = $compiled - Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1 + Crashed = $crashed + Green = (-not $crashed) -and $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1 Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 } Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 } } @@ -169,10 +188,18 @@ foreach ($mutation in $mutations) { try { foreach ($file in $files) { Write-Text $targets[$file] $texts[$file] } $run = Run-Tests $mutation.Killer - if (-not $run.Compiled) { $verdict = 'NO VERDICT (did not compile)' } + if ($run.Crashed) { $verdict = 'NO VERDICT (test host crashed)' } + elseif (-not $run.Compiled) { $verdict = 'NO VERDICT (did not compile)' } elseif ($run.Failed -ge 1) { $verdict = 'KILLED' } else { $verdict = 'SURVIVED' } } + catch { + # Not yet reached by Run-Tests itself (it no longer throws on a host crash), but a defensive + # backstop: any other exception during this entry's run is recorded and the campaign continues + # rather than aborting every remaining entry, never labeled "unreachable". + Write-Host "NO VERDICT: $($mutation.Id) - test run threw: $_" + $verdict = 'NO VERDICT (harness exception)' + } finally { foreach ($file in $files) { [System.IO.File]::WriteAllBytes($targets[$file], $originalBytes[$file]) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index ac2053d5..9512f52b 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -2723,10 +2723,16 @@ namespace GameCult.Mesh } // One exact read (docs/cultnet-selection-cut.md, section 4/7): the record this recordKey - // names, decoded as TDocument when its schema is this schema, a declared alias of it, or - // (a foreign/runtime-generated schema id this runtime does not recognize by any alias) its - // payload decodes as TDocument. No re-filter of the server's answer by schema alone, and no - // fallback that returns a record under the wrong key. + // names, decoded as TDocument. Candidates at that key are searched in one order - exact + // schema, then a declared alias of it, then (a foreign/runtime-generated schema id this + // runtime does not recognize by any alias) its payload's own embedded schema stamp, + // alias-matched against TDocument's descriptor. The parse step + // (CultNetDocumentRegistry.TryReadSchemaVersion) is the one decode rule shared with + // DecodeSnapshotDocuments; the match itself stays a direct alias-match against the caller's + // own descriptor rather than a global "which registered type owns this schema string" lookup, + // because that lookup is ambiguous when two document types alias the same schema id (as + // MeshNoteDocument/MeshNoteAliasDocument do in tests/GameCult.Mesh.Tests). No re-filter of the + // server's answer by schema alone, and no fallback that returns a record under the wrong key. private static TDocument ReadDocumentFromSnapshotResponse( CultNetSnapshotResponseRawMessage response, string schemaId, @@ -2735,20 +2741,27 @@ namespace GameCult.Mesh { if (response == null) throw new ArgumentNullException(nameof(response)); var descriptor = CultDocumentRegistry.Shared.GetRequired(); - CultNetRawDocumentRecord? record = null; + + CultNetRawDocumentRecord? exact = null; + CultNetRawDocumentRecord? aliased = null; + CultNetRawDocumentRecord? byPayload = null; + foreach (var candidate in response.Documents) { if (!string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal)) continue; - if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) || - CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor) || - TryDecodeAsMessagePack(candidate, out _)) - { - record = candidate; - break; - } + + if (exact == null && string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal)) + exact = candidate; + else if (aliased == null && CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor)) + aliased = candidate; + else if (byPayload == null && + CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion && + CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor)) + byPayload = candidate; } + var record = exact ?? aliased ?? byPayload; if (record == null) { throw new InvalidOperationException( @@ -2764,23 +2777,6 @@ namespace GameCult.Mesh return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload); } - private static bool TryDecodeAsMessagePack(CultNetRawDocumentRecord record, out TDocument? document) - where TDocument : class - { - document = null; - if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) - return false; - try - { - document = (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload); - return true; - } - catch - { - return false; - } - } - /// /// Parses a rudp://host:port endpoint into its host/port parts. /// diff --git a/src/GameCult.Mesh/CultMeshBodyDemand.cs b/src/GameCult.Mesh/CultMeshBodyDemand.cs index cd2a6370..4293dba4 100644 --- a/src/GameCult.Mesh/CultMeshBodyDemand.cs +++ b/src/GameCult.Mesh/CultMeshBodyDemand.cs @@ -188,7 +188,7 @@ namespace GameCult.Mesh SubscriptionId = Require(subscriptionId, nameof(subscriptionId)); ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId)); BodyId = Require(bodyId, nameof(bodyId)); - Selection = new CultNetSelection + _selection = new CultNetSelection { Keys = new[] { @@ -215,8 +215,19 @@ namespace GameCult.Mesh public string ConsumerRuntimeId { get; } public string BodyId { get; } - /// The view record, the body's latest-publication record, and any additional keys/schemas, as one selection. - public CultNetSelection Selection { get; } + private readonly CultNetSelection _selection; + + /// + /// The view record, the body's latest-publication record, and any additional keys/schemas, as + /// one selection. Returns a fresh copy on every read: 's arrays + /// are wire-mutable by design, and this contract does not let a caller mutate them out from + /// under it (docs/cultnet-selection-cut.md, S2-8). + /// + public CultNetSelection Selection => new() + { + Schemas = (string[]?)_selection.Schemas?.Clone(), + Keys = (string[]?)_selection.Keys?.Clone() + }; private static string Require(string value, string parameterName) => string.IsNullOrWhiteSpace(value) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 5d44a366..0602c789 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -148,6 +148,7 @@ namespace GameCult.Mesh (_client as ICultNetSchemaClientHealth)?.BackgroundFailure) .ConfigureAwait(false); var selection = CultMesh.OverlaySelection(_defaults.Selection, schemaIds, recordKeys); + CultMesh.EnsureV0Compatible(selection); var options = _defaults with { Selection = selection }; var messageId = CultMesh.CreateSnapshotMessageId(options); var completion = new TaskCompletionSource( @@ -729,6 +730,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint)); var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions(); + EnsureV0Compatible(resolvedOptions.Selection); var messageId = CreateSnapshotMessageId(resolvedOptions); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -819,6 +821,34 @@ namespace GameCult.Mesh }; } + /// + /// Refuses, loudly and typed, any selection term the v0 CultNet wire cannot carry. Mesh keeps + /// sending v0 in this cut (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): only + /// schemas and keys reach the wire message, so fields, cites, + /// cited, limit, cursor, descending, and any non-default + /// projection are refused here instead of being silently dropped. A v1-carrying Mesh + /// transport is FU-Mesh-v1, not this cut. + /// + internal static void EnsureV0Compatible(CultNetSelection? selection) + { + if (selection == null) + return; + if (selection.Fields != null) + throw new CultNetSelectionInvalidException("fields", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.fields cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cites != null) + throw new CultNetSelectionInvalidException("cites", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cites cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cited != null) + throw new CultNetSelectionInvalidException("cited", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cited cannot reach the wire (FU-Mesh-v1)."); + if (selection.Limit != null) + throw new CultNetSelectionInvalidException("limit", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.limit cannot reach the wire (FU-Mesh-v1)."); + if (selection.Cursor != null) + throw new CultNetSelectionInvalidException("cursor", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cursor cannot reach the wire (FU-Mesh-v1)."); + if (selection.Descending) + throw new CultNetSelectionInvalidException("descending", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.descending cannot reach the wire (FU-Mesh-v1)."); + if (selection.Projection != CultNetSelectionProjections.Header) + throw new CultNetSelectionInvalidException("projection", selection.Projection, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.projection cannot reach the wire (FU-Mesh-v1)."); + } + /// /// Resolves one selection for a typed convenience call: explicit /// win; otherwise an explicit, non-empty means no schema filter; @@ -830,36 +860,37 @@ namespace GameCult.Mesh IReadOnlyList? recordKeys, CultDocumentDescriptor descriptor) { + var cleanedKeys = Clean(recordKeys); var schemas = schemaIds != null ? Clean(schemaIds) - : (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId }); - return new CultNetSelection { Schemas = schemas, Keys = Clean(recordKeys) }; + : (cleanedKeys != null ? null : new[] { descriptor.SchemaId }); + return new CultNetSelection { Schemas = schemas, Keys = cleanedKeys }; } - /// Overlays explicit schema/record-key overrides onto a default selection. + /// + /// Overlays explicit schema/record-key overrides onto a default selection: the caller's + /// selection replaces the default wholesale rather than merging field by field, so neither + /// side decides what a missing override means (docs/cultnet-selection-cut.md, Self's rulings + /// 2026-09-22, S2-7). No override at all keeps the default selection untouched. + /// internal static CultNetSelection OverlaySelection( CultNetSelection? defaults, IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) { + if (schemaIds == null && recordKeys == null) + return defaults ?? new CultNetSelection(); + return new CultNetSelection { - Schemas = schemaIds != null ? Clean(schemaIds) : defaults?.Schemas, - Keys = recordKeys != null ? Clean(recordKeys) : defaults?.Keys + Schemas = Clean(schemaIds), + Keys = Clean(recordKeys) }; } - private static string[]? Clean(IReadOnlyList? values) - { - if (values is not { Count: > 0 }) - return null; - - var filtered = values - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - return filtered.Length == 0 ? null : filtered; - } + // The v0-compatibility list cleaning is CultNetV0SelectionLowering's, not a second copy here + // (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22). + private static string[]? Clean(IReadOnlyList? values) => CultNetV0SelectionLowering.Lower(values); internal static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options) { @@ -943,7 +974,8 @@ namespace GameCult.Mesh var canDeserializeAsSchemaAlias = CultNetSchemaAliasMatching.Matches(record.SchemaId, descriptor) || (binding != null && CultNetSchemaAliasMatching.Matches(binding.SchemaId, descriptor)) || - RawSnapshotPayloadMatchesSchema(record.Payload, descriptor); + (CultNetDocumentRegistry.TryReadSchemaVersion(record.Payload) is { } payloadSchemaVersion && + CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor)); if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias) continue; @@ -968,47 +1000,5 @@ namespace GameCult.Mesh return documents; } - - // Decode-time fallback for a wire schemaId this runtime does not recognize by any alias - // (e.g. a foreign/runtime-generated id): trust the payload's own embedded schemaVersion - // instead. This is a decode concern, not a selector engine — the server's answer is not - // re-filtered here, and schema identity itself still goes through the one alias matcher. - private static bool RawSnapshotPayloadMatchesSchema( - byte[] payload, - CultDocumentDescriptor descriptor) - { - var schemaVersion = TryReadSchemaVersion(payload); - return !string.IsNullOrWhiteSpace(schemaVersion) && - CultNetSchemaAliasMatching.Matches(schemaVersion!, descriptor); - } - - private static string? TryReadSchemaVersion(byte[] payload) - { - try - { - var array = MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options); - if (array.Length > 0 && array[0] is string schemaVersion) - return schemaVersion; - } - catch (Exception) - { - // Fall through to map decoding; different runtimes may encode object-like payloads. - } - - try - { - var map = MessagePackSerializer.Deserialize>(payload, CultNetSchemaMessageSerialization.Options); - if (map.TryGetValue("schemaVersion", out var schemaVersion) && schemaVersion is string schemaVersionText) - return schemaVersionText; - if (map.TryGetValue("schema_version", out var snakeSchemaVersion) && snakeSchemaVersion is string snakeSchemaVersionText) - return snakeSchemaVersionText; - } - catch (Exception) - { - return null; - } - - return null; - } } } diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 13600a04..238341fd 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -628,7 +628,10 @@ namespace GameCult.Networking } /// - /// Creates a raw document snapshot bounded to one shard. + /// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator + /// (, via ), + /// binding-alias expansion included, with the shard membership check as the row filter - + /// no loop of its own (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, S2-3). /// public CultNetSnapshotResponseRawMessage CreateShardSnapshotResponse( CultNetShardDescriptor shard, @@ -638,26 +641,25 @@ namespace GameCult.Networking ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); - var requestedSchemaIds = filter?.SchemaIds; - var requestedRecordKeys = filter?.RecordKeys != null - ? new HashSet(filter.RecordKeys, StringComparer.Ordinal) - : null; - var documents = new List(); - foreach (var document in _cache.AllEntries) + var selection = new CultNetSelection { - var documentType = document.GetType(); - var descriptor = _cache.Registry.GetRequired(documentType); - var key = GetTrackedKey(document, documentType); - if (string.IsNullOrWhiteSpace(key.Value) || - !shard.Matches(descriptor.SchemaId, key) || - (requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(requestedSchemaIds, descriptor)) || - (requestedRecordKeys != null && !requestedRecordKeys.Contains(key.Value))) - { - continue; - } + Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds), + Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys), + Projection = CultNetSelectionProjections.Document, + Limit = CultNetSelectionEvaluator.LimitMax + }; + bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) => + !string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key); - documents.Add(CreateRawDocumentRecord(key, document)); - } + var documents = new List(); + string? cursor = null; + do + { + selection.Cursor = cursor; + var page = _documents.SelectPage(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, validate: false, options: null, rowFilter: RowFilter); + documents.AddRange(page.Documents ?? Array.Empty()); + cursor = page.Next; + } while (cursor != null); return new CultNetSnapshotResponseRawMessage { diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 7d5378ef..caeda46e 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -251,6 +251,9 @@ namespace GameCult.Networking ? CreateProjectedSnapshot(request, key.Peer) : ProjectedSnapshot.Empty; + // With _projectRecord gone (docs/cultnet-selection-cut.md, S2-2), a projection's dictionary + // key is always the record's own RecordKey/SchemaId, so the two can never disagree here; + // the identity branch that used to catch a re-projected key is dead and deleted. foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray()) { if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current)) @@ -258,13 +261,7 @@ namespace GameCult.Networking SendRemoval(key.Peer, key.Id, previous.Value); continue; } - if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) || - !string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal)) - { - SendRemoval(key.Peer, key.Id, previous.Value); - SendUpsert(key.Peer, key.Id, current, added: true); - } - else if (!Equivalent(previous.Value, current)) + if (!Equivalent(previous.Value, current)) { SendUpsert(key.Peer, key.Id, current, added: false); } @@ -299,16 +296,15 @@ namespace GameCult.Networking SchemaIds = request.SchemaIds, RecordKeys = request.RecordKeys }); + // The snapshot is one row per record key by construction, so the duplicate-key throw this + // used to guard can no longer fire (docs/cultnet-selection-cut.md, S2-2); deleted rather + // than tested around. var bySourceRecordKey = new Dictionary(StringComparer.Ordinal); - var matchedRecordKeys = new HashSet(StringComparer.Ordinal); foreach (var source in snapshot.Documents) { var sourceRecordKey = source.RecordKey; if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false) continue; - if (!matchedRecordKeys.Add(source.RecordKey)) - throw new InvalidOperationException( - $"Database subscription snapshot produced duplicate record key '{source.RecordKey}'."); bySourceRecordKey[sourceRecordKey] = source; } return new ProjectedSnapshot(bySourceRecordKey); @@ -337,14 +333,8 @@ namespace GameCult.Networking SendUpsert(peer, subscriptionId, current, added: true); return; } - if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) || - !string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal)) - { - SendRemoval(peer, subscriptionId, previous); - projection.DeliveredBySourceRecordKey[sourceRecordKey] = current; - SendUpsert(peer, subscriptionId, current, added: true); - return; - } + // Same S2-2 fact as Reconcile: previous/current are looked up by the record's own key, so + // they can never disagree in RecordKey/SchemaId here either. if (!Equivalent(previous, current)) { projection.DeliveredBySourceRecordKey[sourceRecordKey] = current; diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 9bcafa27..c79e660b 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -285,8 +285,8 @@ namespace GameCult.Networking if (cache == null) throw new ArgumentNullException(nameof(cache)); var selection = new CultNetSelection { - Schemas = filter?.SchemaIds, - Keys = filter?.RecordKeys, + Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds), + Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys), Projection = CultNetSelectionProjections.Document, Limit = CultNetSelectionEvaluator.LimitMax }; @@ -350,15 +350,19 @@ namespace GameCult.Networking Func ordinalOf, ulong asOf, bool validate, - CultNetDocumentMessageOptions? options) + CultNetDocumentMessageOptions? options, + Func? rowFilter = null) { selection = ExpandSchemaBindingAliases(selection); if (validate) selection.Validate(_documents.AllDescriptors.ToArray()); - var rows = cache.AllStoredDocuments - .Select(stored => new CultNetSelectionEvaluator.Row( - stored.Descriptor, stored.Key, stored.Document, ordinalOf(stored.Descriptor.SchemaId, stored.Key), stored.StoredAt)) + var stored = cache.AllStoredDocuments; + if (rowFilter != null) + stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key)); + var rows = stored + .Select(entry => new CultNetSelectionEvaluator.Row( + entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) .ToArray(); var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf); var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray(); @@ -593,7 +597,19 @@ namespace GameCult.Networking string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal)); } - private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) + /// + /// Resolves the descriptor this registry's own bindings (or every registered descriptor, when + /// none are bound) associate with a raw payload's embedded schema version, when exactly one + /// registered type owns that schema string. Used by + /// to recover a document's type from a foreign/runtime-generated schema id. Ambiguous - and + /// so not the right tool - when the caller already knows the target descriptor and two + /// registered types alias the same schema id: match 's + /// result against that descriptor with directly + /// instead (docs/cultnet-selection-cut.md, section 4/S2-5; this is what CultMesh's + /// foreign-schema decode does). Trusts the payload's own schema stamp; it does not re-run a + /// selection or re-check authorization. + /// + public CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload) { var schemaVersion = TryReadSchemaVersion(payload); if (string.IsNullOrWhiteSpace(schemaVersion)) @@ -624,7 +640,16 @@ namespace GameCult.Networking .Select(group => group.First()); } - private static string? TryReadSchemaVersion(byte[] payload) + /// + /// Reads a raw payload's embedded schemaVersion stamp, when it carries one - the parse + /// step of the registry's payload-schema decode rule, shared with + /// and every "foreign schema id" fallback in + /// CultNet and CultMesh (docs/cultnet-selection-cut.md, section 4/S2-5). A caller matching + /// against one already-known descriptor (rather than resolving "whichever registered type owns + /// this schema string", which is ambiguous when two document types alias the same schema id) + /// should alias-match this string with directly. + /// + public static string? TryReadSchemaVersion(byte[] payload) { try { diff --git a/src/GameCult.Networking/CultNetSelection.cs b/src/GameCult.Networking/CultNetSelection.cs index db533607..9997d752 100644 --- a/src/GameCult.Networking/CultNetSelection.cs +++ b/src/GameCult.Networking/CultNetSelection.cs @@ -195,6 +195,31 @@ namespace GameCult.Networking public bool HasHop => Cites != null || Cited != null; } + /// + /// Lowers a v0 schemaIds/recordKeys filter into cultnet.selection.v1's null-means- + /// every vocabulary (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0's old cleaning + /// carried over as the one place that decides it. An absent list, an empty one, or one made only + /// of blank entries all lower to null; a mixed list keeps its non-blank entries, deduplicated. + /// This is a v0-compatibility rule, not a meaning or + /// carries - v1 selections are refused at the door + /// instead (S1). + /// + public static class CultNetV0SelectionLowering + { + /// Lowers one v0 filter list; see the type summary. + public static string[]? Lower(IReadOnlyList? values) + { + if (values is not { Count: > 0 }) + return null; + + var filtered = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return filtered.Length == 0 ? null : filtered; + } + } + /// The two wire values may carry. public static class CultNetSelectionProjections { @@ -302,6 +327,22 @@ namespace GameCult.Networking throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema."); if (selection.Keys != null && selection.Keys.Length == 0) throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key."); + if (selection.Schemas != null) + { + foreach (var schema in selection.Schemas) + { + if (string.IsNullOrWhiteSpace(schema)) + throw new CultNetSelectionInvalidException("schemas", schema, "selection.schemas carries an empty or whitespace entry."); + } + } + if (selection.Keys != null) + { + foreach (var key in selection.Keys) + { + if (string.IsNullOrWhiteSpace(key)) + throw new CultNetSelectionInvalidException("keys", key, "selection.keys carries an empty or whitespace entry."); + } + } if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document)) throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\"."); diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs index c2062033..8d22f60e 100644 --- a/src/GameCult.Networking/CultNetSelectionEvaluator.cs +++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs @@ -79,9 +79,13 @@ namespace GameCult.Networking private static bool MatchesSchemaKeysFields(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection) { - if (selection.Schemas is { Length: > 0 } && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)) + // The door (CultNetSelectionValidation.Validate) refuses an empty schemas/keys list before + // a selection ever reaches evaluation, so a non-null array here is always non-empty; the + // evaluator does not special-case [] as "no filter" (docs/cultnet-selection-cut.md, + // Self's rulings 2026-09-22, S2-3). + if (selection.Schemas != null && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)) return false; - if (selection.Keys is { Length: > 0 } && !selection.Keys.Contains(key.Value, StringComparer.Ordinal)) + if (selection.Keys != null && !selection.Keys.Contains(key.Value, StringComparer.Ordinal)) return false; if (selection.Fields == null) return true; diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 6b1a9d6b..f829b369 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2745,6 +2745,94 @@ public sealed class CultMeshStreamingTests CultMesh.Verse("starbridge", "unity-pilot")); (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. + var caseVariantKey = key.Value.ToUpperInvariant(); + var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot( + _ => Task.FromResult(new CultNetSnapshotResponseRawMessage + { + MessageId = "case-variant-key", + Documents = new[] + { + new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = caseVariantKey, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "wrong-case-key", + Revision = 13 + }, + typeof(MeshNoteDocument)) + }, + new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = key.Value, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + PayloadEncoding = "messagepack", + Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "right-case-key", + Revision = 14 + }, + typeof(MeshNoteDocument)) + } + } + }), + key.Value, + CultMesh.Verse("starbridge", "unity-pilot")); + + (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. + [Test] + public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema() + { + var cache = new CultCache(); + var key = new CultRecordKey("mesh-note:empty-keys-default"); + await cache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "empty-keys-default", + Revision = 1 + }, new CultRecordHandle(key)); + var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); + var requests = new List(); + + using var session = CultMesh.SnapshotSession( + "cultnet://empty-keys-default.test:3075", + new CultMeshSnapshotRequestOptions + { + CreateClient = () => new MeshSnapshotSchemaClient(request => + { + requests.Add(request); + return registry.CreateRawSnapshotResponse(cache, request.MessageId, request); + }) + }, + registry); + + var documents = await session.FetchDocumentsAsync(recordKeys: Array.Empty()); + + documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default"); + requests.Should().ContainSingle(); + requests[0].SchemaIds.Should().ContainSingle().Which + .Should().Be(CultDocumentRegistry.Shared.GetRequired().SchemaId); + requests[0].RecordKeys.Should().BeNull(); } [Test] diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index d420eb6d..e75f9d2a 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1425,6 +1425,7 @@ namespace GameCult.Networking.Tests var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-host-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.host")); @@ -1488,6 +1489,7 @@ namespace GameCult.Networking.Tests await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); await FetchOnce("second"); cancellation.Cancel(); + serverThread.Join(); } // A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made @@ -1577,6 +1579,7 @@ namespace GameCult.Networking.Tests var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(update.SubscriptionId, Is.EqualTo("note")); Assert.That(update.ChangeKind, Is.EqualTo("added")); Assert.That(update.Document, Is.Not.Null); @@ -1654,6 +1657,7 @@ namespace GameCult.Networking.Tests var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live")); } @@ -1749,11 +1753,98 @@ namespace GameCult.Networking.Tests }); await Task.Delay(100); cancellation.Cancel(); + serverThread.Join(); Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation)); Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null); } + // Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md, + // Self's rulings 2026-09-22): flip authorizeRecord's answer per key while the whole request + // stays authorized (so DemandActive never toggles), and prove both halves of the diff - + // removing a now-unauthorized key and the add loop picking up a newly-authorized one. + [Test] + public async Task DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority() + { + const string schemaId = "tests.networking_note"; + const string recordKeyA = "tests:record-authority:a"; + const string recordKeyB = "tests:record-authority:b"; + var authorizedKeys = new HashSet(StringComparer.Ordinal) { recordKeyA }; + var cache = new CultCache(); + var database = new CultNetDatabase(cache); + await database.PutAsync(new CultRecordKey(recordKeyA), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "a" + }); + await database.PutAsync(new CultRecordKey(recordKeyB), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "b" + }); + + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "database-record-authority-subscription-server", + Socket = BindUdpSocket() + }); + using var subscriptions = new CultNetDatabaseSubscriptionServer( + server, + database, + authorizeRequest: (_, _) => true, + authorizeRecord: (_, _, recordKey, _) => authorizedKeys.Contains(recordKey)); + using var cancellation = new CancellationTokenSource(); + var serverThread = new Thread(() => + { + while (!cancellation.IsCancellationRequested) + { + _ = server.PollOnceAsync().GetAwaiter().GetResult(); + Thread.Sleep(1); + } + }) { IsBackground = true }; + serverThread.Start(); + try + { + using var client = CultNetSchemaClients.CreateRudp("database-record-authority-subscription-client"); + var changes = new ConcurrentQueue(); + var subscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(message => subscribed.TrySetResult(message)); + client.OnCultNet(message => changes.Enqueue(message)); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2)); + client.SendCultNet(new CultNetDatabaseSubscribeMessage + { + MessageId = "subscribe-record-authority", + SubscriptionId = "record-authority", + ConsumerRuntimeId = "record-authority-client", + SchemaIds = new[] { schemaId }, + IncludeSnapshot = true + }); + var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2)); + Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKeyA })); + + authorizedKeys.Clear(); + authorizedKeys.Add(recordKeyB); + subscriptions.Reconcile(); + + // SendRemoval carries the removed key on the message's own RecordKey field (no + // Document); SendUpsert carries the full record under Document. + await WaitUntilAsync( + () => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA) && + changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), + TimeSpan.FromSeconds(2)); + + Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA), Is.True); + Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), Is.True); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } + } + [Test] public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription() { @@ -1814,6 +1905,7 @@ namespace GameCult.Networking.Tests }); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity")); Assert.That(removed.Active, Is.False); } @@ -1962,6 +2054,7 @@ namespace GameCult.Networking.Tests var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" })); Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" })); Assert.That(observed.BodyIds, Is.Empty); @@ -2015,6 +2108,7 @@ namespace GameCult.Networking.Tests var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body")); Assert.That(removed.Active, Is.False); @@ -2072,6 +2166,7 @@ namespace GameCult.Networking.Tests var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(update.SubscriptionId, Is.EqualTo("notes")); Assert.That(update.Document, Is.TypeOf()); Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live")); @@ -2132,6 +2227,7 @@ namespace GameCult.Networking.Tests var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); } @@ -2196,6 +2292,7 @@ namespace GameCult.Networking.Tests update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(update.Text, Is.EqualTo("updated")); Assert.That(value.Current.Text, Is.EqualTo("updated")); } @@ -2246,6 +2343,7 @@ namespace GameCult.Networking.Tests await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); } @@ -2300,6 +2398,7 @@ namespace GameCult.Networking.Tests var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(update.SchemaId, Is.EqualTo(wireSchema)); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live")); } @@ -2418,6 +2517,7 @@ namespace GameCult.Networking.Tests () => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0, TimeSpan.FromSeconds(2)); cancellation.Cancel(); + serverThread.Join(); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024)); Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024), @@ -2498,6 +2598,7 @@ namespace GameCult.Networking.Tests if (completed != receivedAll.Task) Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages."); cancellation.Cancel(); + serverThread.Join(); Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages)); } @@ -3225,6 +3326,100 @@ namespace GameCult.Networking.Tests Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType()))); } + // S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): the shard snapshot answers + // through the same evaluator as the non-shard path, no loop of its own. An unfiltered request + // ({schemas:null, keys:null}) must return the same rows from both. + [Test] + public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "unfiltered-notes", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "unfiltered-note:", + primaryEndpoints: ["cultnet://runtime-a:3080"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + await database.PutAsync(new CultRecordKey("unfiltered-note:one"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "one" + }); + await database.PutAsync(new CultRecordKey("unfiltered-note:two"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "two" + }); + // Outside the shard's key prefix: must reach the non-shard snapshot but never the + // shard-scoped one, so an unfiltered request still proves shard membership is enforced. + await database.PutAsync(new CultRecordKey("other-note:outside-shard"), new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "outside" + }); + + var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-unfiltered", filter: null); + var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-unfiltered", filter: null); + + Assert.That( + shardScoped.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), + Is.EqualTo(new[] { "unfiltered-note:one", "unfiltered-note:two" })); + Assert.That( + nonShard.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal), + Is.EqualTo(new[] { "other-note:outside-shard", "unfiltered-note:one", "unfiltered-note:two" })); + Assert.That(shardScoped.Documents, Has.Length.EqualTo(2)); + } + + // S2-3, second probe: an override of the binding schema id (rather than the descriptor's own) + // must match on both the shard and non-shard paths - both run binding-alias expansion. + [Test] + public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnBindingSchemaIdOverride() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + var descriptor = cache.Registry.GetRequired(); + var shard = new CultNetShardDescriptor( + "override-notes", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [descriptor.SchemaId], + keyPrefix: "override-note:", + primaryEndpoints: ["cultnet://runtime-a:3081"]); + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = [shard] + }); + var key = new CultRecordKey("override-note:one"); + await database.PutAsync(key, new NetworkSchemaNote + { + Schema = "tests.networking_note.v1", + Text = "override" + }); + + var request = registry.CreateSnapshotRequest( + "snapshot-override-request", + schemaIds: ["tests.networking_note.v1"], + recordKeys: [key.Value]); + var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-override", request); + var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-override", request); + + Assert.That(nonShard.Documents, Has.Length.EqualTo(1)); + Assert.That(shardScoped.Documents, Has.Length.EqualTo(1)); + Assert.That(shardScoped.Documents[0].RecordKey, Is.EqualTo(nonShard.Documents[0].RecordKey)); + } + [Test] public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias() { @@ -3596,6 +3791,7 @@ namespace GameCult.Networking.Tests var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2)); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.MessageId, Is.EqualTo("rudp-schema-client-test")); Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.test")); @@ -3665,6 +3861,7 @@ namespace GameCult.Networking.Tests }); var response = await client.FetchAsync($"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}"); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.Verses.Single().VerseId, Is.EqualTo("rudp-verse")); } @@ -3734,6 +3931,7 @@ namespace GameCult.Networking.Tests $"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}", new CultMeshPeerExchangeRequestMessage { VerseId = "local" }); await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2)); + serverThread.Join(); Assert.That(response.Peers.Single().PeerId, Is.EqualTo("rudp-peer")); }