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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 06:12:49 +02:00
commit 568e8e356f
14 changed files with 586 additions and 174 deletions

View file

@ -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 = ''
}
)

View file

@ -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<TDocument>(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 = ''
}
)

View file

@ -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

View file

@ -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])

View file

@ -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<TDocument>(
CultNetSnapshotResponseRawMessage response,
string schemaId,
@ -2735,20 +2741,27 @@ namespace GameCult.Mesh
{
if (response == null) throw new ArgumentNullException(nameof(response));
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
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<TDocument>(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<TDocument>(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;
}
}
/// <summary>
/// Parses a rudp://host:port endpoint into its host/port parts.
/// </summary>

View file

@ -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; }
/// <summary>The view record, the body's latest-publication record, and any additional keys/schemas, as one selection.</summary>
public CultNetSelection Selection { get; }
private readonly CultNetSelection _selection;
/// <summary>
/// The view record, the body's latest-publication record, and any additional keys/schemas, as
/// one selection. Returns a fresh copy on every read: <see cref="CultNetSelection"/>'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).
/// </summary>
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)

View file

@ -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<CultNetSnapshotResponseRawMessage>(
@ -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<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
@ -819,6 +821,34 @@ namespace GameCult.Mesh
};
}
/// <summary>
/// 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
/// <c>schemas</c> and <c>keys</c> reach the wire message, so <c>fields</c>, <c>cites</c>,
/// <c>cited</c>, <c>limit</c>, <c>cursor</c>, <c>descending</c>, and any non-default
/// <c>projection</c> are refused here instead of being silently dropped. A v1-carrying Mesh
/// transport is FU-Mesh-v1, not this cut.
/// </summary>
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).");
}
/// <summary>
/// Resolves one selection for a typed convenience call: explicit <paramref name="schemaIds"/>
/// win; otherwise an explicit, non-empty <paramref name="recordKeys"/> means no schema filter;
@ -830,36 +860,37 @@ namespace GameCult.Mesh
IReadOnlyList<string>? 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 };
}
/// <summary>Overlays explicit schema/record-key overrides onto a default selection.</summary>
/// <summary>
/// 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.
/// </summary>
internal static CultNetSelection OverlaySelection(
CultNetSelection? defaults,
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? 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<string>? 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<string>? 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<object[]>(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<IReadOnlyDictionary<string, object?>>(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;
}
}
}

View file

@ -628,7 +628,10 @@ namespace GameCult.Networking
}
/// <summary>
/// Creates a raw document snapshot bounded to one shard.
/// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator
/// (<see cref="CultNetSelectionEvaluator"/>, via <see cref="CultNetDocumentRegistry.SelectPage"/>),
/// 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).
/// </summary>
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<string>(filter.RecordKeys, StringComparer.Ordinal)
: null;
var documents = new List<CultNetRawDocumentRecord>();
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<CultNetRawDocumentRecord>();
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<CultNetRawDocumentRecord>());
cursor = page.Next;
} while (cursor != null);
return new CultNetSnapshotResponseRawMessage
{

View file

@ -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<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
var matchedRecordKeys = new HashSet<string>(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;

View file

@ -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<string, CultRecordKey, long> ordinalOf,
ulong asOf,
bool validate,
CultNetDocumentMessageOptions? options)
CultNetDocumentMessageOptions? options,
Func<CultDocumentDescriptor, CultRecordKey, bool>? 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)
/// <summary>
/// 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 <see cref="ResolveDescriptorForRawDocument"/>
/// 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 <see cref="TryReadSchemaVersion"/>'s
/// result against that descriptor with <see cref="CultNetSchemaAliasMatching"/> 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.
/// </summary>
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)
/// <summary>
/// Reads a raw payload's embedded <c>schemaVersion</c> stamp, when it carries one - the parse
/// step of the registry's payload-schema decode rule, shared with
/// <see cref="TryResolveDescriptorByPayloadSchema"/> 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 <see cref="CultNetSchemaAliasMatching"/> directly.
/// </summary>
public static string? TryReadSchemaVersion(byte[] payload)
{
try
{

View file

@ -195,6 +195,31 @@ namespace GameCult.Networking
public bool HasHop => Cites != null || Cited != null;
}
/// <summary>
/// Lowers a v0 <c>schemaIds</c>/<c>recordKeys</c> 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 <see cref="CultNetSelectionEvaluator"/> or
/// <see cref="CultNetSelectionValidation"/> carries - v1 selections are refused at the door
/// instead (S1).
/// </summary>
public static class CultNetV0SelectionLowering
{
/// <summary>Lowers one v0 filter list; see the type summary.</summary>
public static string[]? Lower(IReadOnlyList<string>? 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;
}
}
/// <summary>The two wire values <see cref="CultNetSelection.Projection"/> may carry.</summary>
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\".");

View file

@ -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;

View file

@ -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<MeshNoteAliasDocument>(
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
{
MessageId = "case-variant-key",
Documents = new[]
{
new CultNetRawDocumentRecord
{
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().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<MeshNoteDocument>().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<MeshNoteDocument>(key));
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(CultDocumentRegistry.Shared));
var requests = new List<CultNetSnapshotRequestMessage>();
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<MeshNoteDocument>(recordKeys: Array.Empty<string>());
documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default");
requests.Should().ContainSingle();
requests[0].SchemaIds.Should().ContainSingle().Which
.Should().Be(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId);
requests[0].RecordKeys.Should().BeNull();
}
[Test]

View file

@ -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<string>(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<CultNetDatabaseChangeRawMessage>();
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(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<NetworkSchemaNote>());
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<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
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<NetworkSchemaNote>(cache.Registry));
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
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"));
}