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

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