CultLib/src/GameCult.Networking/CultNetSchemaRegistry.cs
Meta Vens 17d10e017f CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).

Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.

Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
  cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
  replaces the reflection entirely. v0 lowers into a Selection and walks the
  evaluator's own pages so a v0 caller still sees its whole matching set
  (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
  ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
  MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
  v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
  CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
  (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
  CreateProjectedSnapshot's collapse are commit 2's, per the map's own
  deletion line, and are untouched here.

Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.

Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.

Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).

Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.

scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00

493 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Globalization;
namespace GameCult.Networking
{
/// <summary>
/// Registers exchange-safe schema metadata and produces discovery responses.
/// </summary>
public sealed class CultNetSchemaRegistry
{
private readonly Dictionary<string, RegisteredSchema> _entries = new Dictionary<string, RegisteredSchema>(StringComparer.Ordinal);
/// <summary>
/// Creates a schema registry with optional initial entries.
/// </summary>
public CultNetSchemaRegistry(IEnumerable<CultNetSchemaRegistration>? entries = null)
{
if (entries == null)
{
return;
}
foreach (var entry in entries)
{
Register(entry);
}
}
/// <summary>
/// Adds or replaces one schema registration.
/// </summary>
public CultNetSchemaRegistry Register(CultNetSchemaRegistration entry)
{
if (entry == null) throw new ArgumentNullException(nameof(entry));
if (string.IsNullOrWhiteSpace(entry.SchemaId)) throw new ArgumentException("SchemaId must be non-empty.", nameof(entry));
if (string.IsNullOrWhiteSpace(entry.Kind)) throw new ArgumentException("Kind must be non-empty.", nameof(entry));
if (entry.WireContracts == null || entry.WireContracts.Length == 0)
{
throw new ArgumentException("WireContracts must contain at least one entry.", nameof(entry));
}
var canonicalSchemaJson = CanonicalizeJson(entry.SchemaJson);
var contentHash = ComputeSha256Hex(canonicalSchemaJson);
_entries[entry.SchemaId] = new RegisteredSchema(entry, canonicalSchemaJson, contentHash);
return this;
}
/// <summary>
/// Gets one schema descriptor by schema identifier.
/// </summary>
public CultNetSchemaDescriptor? Get(string schemaId, bool includeSchemaJson = false)
{
if (schemaId == null) throw new ArgumentNullException(nameof(schemaId));
return _entries.TryGetValue(schemaId, out var entry)
? entry.ToDescriptor(includeSchemaJson)
: null;
}
/// <summary>
/// Lists schema descriptors, optionally filtered by identifier or kind.
/// </summary>
public CultNetSchemaDescriptor[] List(
bool includeSchemaJson = false,
IEnumerable<string>? schemaIds = null,
IEnumerable<string>? kinds = null)
{
var requestedSchemaIds = schemaIds != null
? new HashSet<string>(schemaIds, StringComparer.Ordinal)
: null;
var requestedKinds = kinds != null
? new HashSet<string>(kinds, StringComparer.Ordinal)
: null;
return _entries.Values
.Where(entry =>
(requestedSchemaIds == null || requestedSchemaIds.Contains(entry.SchemaId)) &&
(requestedKinds == null || requestedKinds.Contains(entry.Kind)))
.Select(entry => entry.ToDescriptor(includeSchemaJson))
.ToArray();
}
/// <summary>
/// Creates a schema catalog response for a request.
/// </summary>
public CultNetSchemaCatalogResponseMessage CreateCatalogResponse(
CultNetSchemaCatalogRequestMessage request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
return new CultNetSchemaCatalogResponseMessage
{
MessageId = request.MessageId,
Schemas = List(
includeSchemaJson: request.IncludeSchemaJson,
schemaIds: request.SchemaIds,
kinds: request.Kinds)
};
}
/// <summary>
/// Creates a registry populated from embedded CultNet schema resources.
/// </summary>
public static CultNetSchemaRegistry CreateBuiltIn()
{
return new CultNetSchemaRegistry(BuiltInSchemaManifest.Select(LoadEmbeddedRegistration));
}
private static readonly Lazy<CultNetSchemaRegistry> BuiltInRegistry =
new Lazy<CultNetSchemaRegistry>(CreateBuiltIn);
/// <summary>
/// Gets the lazily initialized built-in schema registry.
/// </summary>
public static CultNetSchemaRegistry BuiltIn => BuiltInRegistry.Value;
private static CultNetSchemaRegistration LoadEmbeddedRegistration(SchemaResourceSpec spec)
{
var resourceName = $"GameCult.Networking.Contracts.{spec.FileName}";
using var stream = typeof(CultNetSchemaRegistry).Assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded CultNet schema resource \"{resourceName}\".");
using var reader = new StreamReader(stream, Encoding.UTF8);
var schemaJson = reader.ReadToEnd();
using var document = JsonDocument.Parse(schemaJson);
var root = document.RootElement;
var schemaId = root.GetProperty("$id").GetString()
?? throw new InvalidOperationException($"Schema resource \"{resourceName}\" is missing $id.");
var title = root.TryGetProperty("title", out var titleElement) ? titleElement.GetString() : null;
return new CultNetSchemaRegistration
{
SchemaId = schemaId,
Kind = spec.Kind,
SchemaVersion = spec.SchemaVersion,
DocumentType = spec.DocumentType,
Title = title,
WireContracts = spec.WireContracts.ToArray(),
SchemaJson = schemaJson
};
}
private static string CanonicalizeJson(string schemaJson)
{
using var document = JsonDocument.Parse(schemaJson);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
WriteCanonicalElement(document.RootElement, writer);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static void WriteCanonicalElement(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonicalElement(property.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonicalElement(item, writer);
}
writer.WriteEndArray();
break;
default:
element.WriteTo(writer);
break;
}
}
private static string ComputeSha256Hex(string canonicalSchemaJson)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(canonicalSchemaJson);
var hash = sha.ComputeHash(bytes);
var builder = new StringBuilder(hash.Length * 2);
foreach (var value in hash)
{
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private sealed class RegisteredSchema
{
public RegisteredSchema(CultNetSchemaRegistration registration, string canonicalSchemaJson, string contentHash)
{
SchemaId = registration.SchemaId;
Kind = registration.Kind;
SchemaVersion = registration.SchemaVersion;
DocumentType = registration.DocumentType;
Title = registration.Title;
WireContracts = registration.WireContracts.ToArray();
CanonicalSchemaJson = canonicalSchemaJson;
ContentHash = contentHash;
}
public string SchemaId { get; }
public string Kind { get; }
public string? SchemaVersion { get; }
public string? DocumentType { get; }
public string? Title { get; }
public string[] WireContracts { get; }
public string CanonicalSchemaJson { get; }
public string ContentHash { get; }
public CultNetSchemaDescriptor ToDescriptor(bool includeSchemaJson)
{
return new CultNetSchemaDescriptor
{
SchemaId = SchemaId,
Kind = Kind,
SchemaVersion = SchemaVersion,
DocumentType = DocumentType,
Title = Title,
WireContracts = WireContracts.ToArray(),
ContentHash = ContentHash,
SchemaJson = includeSchemaJson ? CanonicalSchemaJson : null
};
}
}
private sealed class SchemaResourceSpec
{
public string FileName { get; set; } = string.Empty;
public string Kind { get; set; } = string.Empty;
public string[] WireContracts { get; set; } = Array.Empty<string>();
public string? SchemaVersion { get; set; }
public string? DocumentType { get; set; }
}
private static readonly SchemaResourceSpec[] BuiltInSchemaManifest =
{
new SchemaResourceSpec
{
FileName = "cultnet.hello.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.Hello,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.document-mutation-contract.schema.json",
Kind = "shared_contract",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.transport-profile.schema.json",
Kind = "shared_contract",
SchemaVersion = "cultnet.transport_profile.v0",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.login.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.Login,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.register.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.Register,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.verify.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.Verify,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.login-success.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.LoginSuccess,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.error.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.Error,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.sample-change-name.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SampleChangeName,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.sample-chat.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SampleChat,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.operation-request.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.OperationRequest,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.operation-response.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.OperationResponse,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.document-delete.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DocumentDelete,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.raw-document-record.schema.json",
Kind = "shared_contract",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.document-put-raw.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DocumentPutRaw,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-request.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotRequest,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-response-raw.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotResponseRaw,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-subscribe.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseSubscribe,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.selection.schema.json",
Kind = "shared_contract",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-request.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotRequestV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-subscribe.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseSubscribeV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-response-raw.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotResponseRawV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-unsubscribe.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseUnsubscribe,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-change-raw.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseChangeRaw,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.schema-descriptor.schema.json",
Kind = "shared_contract",
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.schema-catalog-request.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SchemaCatalogRequest,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.schema-catalog-response.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SchemaCatalogResponse,
WireContracts = new[] { CultNetWireContracts.SchemaV0, CultNetWireContracts.GameCultNetworkingV0 }
},
new SchemaResourceSpec
{
FileName = "ghostlight.agent-state.schema.json",
Kind = "document_payload",
SchemaVersion = "ghostlight.agent_state.v0",
DocumentType = "ghostlight.agent-state",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.witness-artifact-bundle.schema.json",
Kind = "document_payload",
SchemaVersion = CultNetSchemaVersions.WitnessArtifactBundle,
DocumentType = "cultnet.witness-artifact-bundle",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
}
};
}
/// <summary>
/// One exchange-safe schema registration.
/// </summary>
public sealed class CultNetSchemaRegistration
{
/// <summary>
/// Gets or sets the schema identifier.
/// </summary>
public string SchemaId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the schema kind.
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the wire contracts implemented by the schema.
/// </summary>
public string[] WireContracts { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the schema document.
/// </summary>
public string SchemaJson { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the message or document schema version.
/// </summary>
public string? SchemaVersion { get; set; }
/// <summary>
/// Gets or sets the document type associated with the schema.
/// </summary>
public string? DocumentType { get; set; }
/// <summary>
/// Gets or sets the display title.
/// </summary>
public string? Title { get; set; }
}
}