CultLib/src/GameCult.Networking/CultNetDatabase.cs

2100 lines
91 KiB
C#
Raw Normal View History

2026-05-19 10:44:00 +01:00
using System;
using System.Collections.Concurrent;
2026-05-19 10:44:00 +01:00
using System.Collections.Generic;
using System.Linq;
2026-05-19 19:55:29 +01:00
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
2026-05-19 10:44:00 +01:00
using System.Threading.Tasks;
using GameCult.Caching;
using R3;
namespace GameCult.Networking
{
/// <summary>
/// Describes the kind of domain change observed through a distributed CultCache surface.
/// </summary>
public enum CultNetDatabaseChangeKind
{
/// <summary>
/// A document was added.
/// </summary>
Added,
/// <summary>
/// A document was updated.
/// </summary>
Updated,
/// <summary>
/// A document was removed.
/// </summary>
Removed,
/// <summary>
/// A document was accepted through compatible schema migration.
/// </summary>
SchemaMigrated,
/// <summary>
/// A document change was rejected.
/// </summary>
Rejected,
/// <summary>
/// A document was applied locally before authoritative commit.
/// </summary>
Predicted,
/// <summary>
/// A predicted document was replaced by the authoritative committed state.
/// </summary>
Reconciled
}
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
/// <summary>
/// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4).
/// </summary>
public static class CultNetSchemaAliasMatching
{
public static bool MatchesAny(IReadOnlyList<string> candidates, string schemaId)
{
return candidates.Count == 0 ||
candidates.Any(candidate => Matches(candidate, schemaId));
}
public static bool MatchesAny(IReadOnlyList<string> candidates, CultDocumentDescriptor descriptor)
{
return candidates.Count == 0 ||
candidates.Any(candidate => Matches(candidate, descriptor));
}
public static bool Matches(string candidate, string schemaId)
{
if (string.Equals(candidate, schemaId, StringComparison.Ordinal))
return true;
var candidateName = InferSchemaName(candidate) ?? candidate;
var schemaName = InferSchemaName(schemaId) ?? schemaId;
return string.Equals(candidateName, schemaName, StringComparison.Ordinal);
}
public static bool Matches(string candidate, CultDocumentDescriptor descriptor)
{
if (string.Equals(candidate, descriptor.SchemaId, StringComparison.Ordinal) ||
string.Equals(candidate, descriptor.SchemaName, StringComparison.Ordinal) ||
string.Equals(candidate, descriptor.SchemaVersion, StringComparison.Ordinal))
return true;
if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(compatible =>
string.Equals(candidate, compatible, StringComparison.Ordinal)))
return true;
var candidateName = InferSchemaName(candidate) ?? candidate;
return string.Equals(candidateName, descriptor.SchemaName, StringComparison.Ordinal);
}
private static string? InferSchemaName(string schemaId)
{
var marker = schemaId.LastIndexOf(".v", StringComparison.Ordinal);
if (marker <= 0 || marker + 2 >= schemaId.Length)
return null;
var version = schemaId.Substring(marker + 2);
return version.All(char.IsDigit)
? schemaId.Substring(0, marker)
: null;
}
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
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
// R-M: WithBindingSchemaAlias (a second binding-alias reconciler, scoped to one descriptor) is
// deleted - CultNetDocumentRegistry.ExpandSchemaBindingAliases is the one copy every caller
// uses, CultNetDatabaseServer and CultNetDatabaseSubscriptionServer included.
}
/// <summary>
/// Declares which documents this runtime may predict locally before authoritative commit.
/// </summary>
public sealed class CultNetClientAuthorityScope
{
/// <summary>
/// Creates a client authority scope.
/// </summary>
public CultNetClientAuthorityScope(
string ownerRuntimeId,
IEnumerable<string>? schemaIds = null,
string? keyPrefix = null)
{
OwnerRuntimeId = string.IsNullOrWhiteSpace(ownerRuntimeId)
? throw new ArgumentException("Value must be non-empty.", nameof(ownerRuntimeId))
: ownerRuntimeId;
SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
?? Array.Empty<string>();
KeyPrefix = keyPrefix;
}
/// <summary>
/// Gets the runtime that owns authoring for this scope.
/// </summary>
public string OwnerRuntimeId { get; }
/// <summary>
/// Gets the schema ids governed by this scope. Empty means all schemas.
/// </summary>
public IReadOnlyList<string> SchemaIds { get; }
/// <summary>
/// Gets the optional record-key prefix governed by this scope.
/// </summary>
public string? KeyPrefix { get; }
internal bool Matches(string runtimeId, string schemaId, CultRecordKey key)
{
return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) &&
CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId) &&
(string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal));
}
internal bool Matches(string runtimeId, CultDocumentDescriptor descriptor, CultRecordKey key)
{
return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) &&
CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor) &&
(string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal));
}
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// Describes one shard owned or observed by a CultNet database surface.
/// </summary>
public sealed class CultNetShardDescriptor
{
/// <summary>
/// Creates a shard descriptor.
/// </summary>
public CultNetShardDescriptor(
string shardId,
string ownerRuntimeId,
long epoch,
bool isPrimary,
IEnumerable<string>? schemaIds = null,
2026-05-19 17:34:49 +01:00
string? keyPrefix = null,
IEnumerable<string>? primaryEndpoints = null,
IEnumerable<string>? replicaEndpoints = null,
IEnumerable<string>? readReplicaEndpoints = null,
string? region = null)
2026-05-19 10:44:00 +01:00
{
ShardId = RequireNonEmpty(shardId, nameof(shardId));
OwnerRuntimeId = RequireNonEmpty(ownerRuntimeId, nameof(ownerRuntimeId));
Epoch = epoch;
IsPrimary = isPrimary;
SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
?? Array.Empty<string>();
KeyPrefix = keyPrefix;
2026-05-19 17:34:49 +01:00
PrimaryEndpoints = CleanEndpoints(primaryEndpoints);
ReplicaEndpoints = CleanEndpoints(replicaEndpoints);
ReadReplicaEndpoints = CleanEndpoints(readReplicaEndpoints);
Region = region;
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// Gets the stable shard identifier.
/// </summary>
public string ShardId { get; }
/// <summary>
/// Gets the runtime that currently owns writes for this shard.
/// </summary>
public string OwnerRuntimeId { get; }
/// <summary>
/// Gets the monotonic shard authority epoch.
/// </summary>
public long Epoch { get; }
/// <summary>
/// Gets whether the local database instance is authoritative for writes to this shard.
/// </summary>
public bool IsPrimary { get; }
/// <summary>
/// Gets the schema ids governed by this shard. Empty means all schemas.
/// </summary>
public IReadOnlyList<string> SchemaIds { get; }
/// <summary>
/// Gets the optional record-key prefix governed by this shard.
/// </summary>
public string? KeyPrefix { get; }
2026-05-19 17:34:49 +01:00
/// <summary>
/// Gets endpoints that can accept authoritative writes for this shard.
/// </summary>
public IReadOnlyList<string> PrimaryEndpoints { get; }
/// <summary>
/// Gets endpoints that replicate authoritative shard mutations.
/// </summary>
public IReadOnlyList<string> ReplicaEndpoints { get; }
/// <summary>
/// Gets endpoints intended for low-latency read and subscription traffic.
/// </summary>
public IReadOnlyList<string> ReadReplicaEndpoints { get; }
/// <summary>
/// Gets an optional locality label for this shard owner.
/// </summary>
public string? Region { get; }
2026-05-19 10:44:00 +01:00
/// <summary>
/// Creates a primary shard that accepts every schema and key.
/// </summary>
public static CultNetShardDescriptor PrimaryAll(string ownerRuntimeId = "local", string shardId = "primary")
{
return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch: 1, isPrimary: true);
}
/// <summary>
/// Creates a read-only shard descriptor.
/// </summary>
public static CultNetShardDescriptor ReadOnly(
string shardId,
string ownerRuntimeId,
long epoch = 1,
IEnumerable<string>? schemaIds = null,
string? keyPrefix = null)
{
return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch, isPrimary: false, schemaIds, keyPrefix);
}
internal bool Matches(string schemaId, CultRecordKey key)
{
var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId);
var keyMatches = string.IsNullOrEmpty(KeyPrefix) ||
key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal);
return schemaMatches && keyMatches;
}
internal bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key)
{
var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor);
2026-05-19 10:44:00 +01:00
var keyMatches = string.IsNullOrEmpty(KeyPrefix) ||
key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal);
return schemaMatches && keyMatches;
}
private static string RequireNonEmpty(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value must be non-empty.", paramName);
}
return value;
}
2026-05-19 17:34:49 +01:00
private static string[] CleanEndpoints(IEnumerable<string>? endpoints)
{
return endpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
?? Array.Empty<string>();
}
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// Options for creating a distributed CultCache database surface.
/// </summary>
public sealed class CultNetDatabaseOptions
{
/// <summary>
/// Gets or sets the local runtime id advertised as shard owner for default primary shards.
/// </summary>
public string RuntimeId { get; set; } = "local";
/// <summary>
/// Gets or sets explicit shard descriptors. When omitted, this instance owns one primary shard for all records.
/// </summary>
public IReadOnlyList<CultNetShardDescriptor>? Shards { get; set; }
/// <summary>
/// Gets or sets document scopes this runtime may predict before authoritative commit.
/// </summary>
public IReadOnlyList<CultNetClientAuthorityScope>? ClientAuthorityScopes { get; set; }
/// <summary>
2026-05-19 10:44:00 +01:00
/// Gets or sets the document registry used to create and apply raw CultNet document messages.
/// </summary>
public CultNetDocumentRegistry? DocumentRegistry { get; set; }
2026-05-19 19:55:29 +01:00
/// <summary>
/// Gets or sets the durable store for accepted shard mutation logs.
/// </summary>
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
/// <summary>
/// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key
/// per <see cref="CultNetDatabase"/> is exactly the point; a test that must mint a cursor under a
/// known key, or reuse the pre-restart key deliberately, sets this explicitly.
/// </summary>
public CultNetSelectionCursorKey? CursorKey { get; set; }
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// A typed domain change emitted by a CultNet database surface.
/// </summary>
public sealed class CultNetDatabaseChange<T> where T : class
{
/// <summary>
/// Creates a database change.
/// </summary>
public CultNetDatabaseChange(
CultNetDatabaseChangeKind kind,
CultRecordKey key,
string schemaId,
CultNetShardDescriptor shard,
T? document,
T? previousDocument,
string? message = null)
{
Kind = kind;
Key = key;
SchemaId = schemaId;
Shard = shard;
Document = document;
PreviousDocument = previousDocument;
Message = message;
}
/// <summary>
/// Gets the change kind.
/// </summary>
public CultNetDatabaseChangeKind Kind { get; }
/// <summary>
/// Gets the changed record key.
/// </summary>
public CultRecordKey Key { get; }
/// <summary>
/// Gets the schema id of the changed document.
/// </summary>
public string SchemaId { get; }
/// <summary>
/// Gets the shard that accepted or rejected the change.
/// </summary>
public CultNetShardDescriptor Shard { get; }
/// <summary>
/// Gets the current document for added and updated events.
/// </summary>
public T? Document { get; }
/// <summary>
/// Gets the previous document for updated and removed events.
/// </summary>
public T? PreviousDocument { get; }
/// <summary>
/// Gets an optional diagnostic message.
/// </summary>
public string? Message { get; }
}
2026-05-19 17:52:29 +01:00
/// <summary>
/// One ordered mutation accepted for a shard.
/// </summary>
public sealed class CultNetShardMutationLogEntry
{
/// <summary>
/// Creates a shard mutation log entry.
/// </summary>
public CultNetShardMutationLogEntry(
string shardId,
long shardEpoch,
long sequence,
string committedAt,
CultNetDatabaseChangeKind kind,
string schemaId,
CultRecordKey key,
object? document,
object? previousDocument)
{
ShardId = shardId;
ShardEpoch = shardEpoch;
Sequence = sequence;
CommittedAt = committedAt;
Kind = kind;
SchemaId = schemaId;
Key = key;
Document = document;
PreviousDocument = previousDocument;
}
/// <summary>
/// Gets the shard id.
/// </summary>
public string ShardId { get; }
/// <summary>
/// Gets the shard epoch.
/// </summary>
public long ShardEpoch { get; }
/// <summary>
/// Gets the per-shard mutation sequence.
/// </summary>
public long Sequence { get; }
/// <summary>
/// Gets the commit timestamp.
/// </summary>
public string CommittedAt { get; }
/// <summary>
/// Gets the mutation kind.
/// </summary>
public CultNetDatabaseChangeKind Kind { get; }
/// <summary>
/// Gets the schema id.
/// </summary>
public string SchemaId { get; }
/// <summary>
/// Gets the record key.
/// </summary>
public CultRecordKey Key { get; }
/// <summary>
/// Gets the current document, when present.
/// </summary>
public object? Document { get; }
/// <summary>
/// Gets the previous document, when present.
/// </summary>
public object? PreviousDocument { get; }
// The primary's own wire entry, kept for an entry this replica changed nothing for (a row its shard does not own, an
// absent key): it is relayed downstream exactly as the primary sent it, in memory and on disk alike.
internal CultNetShardLogEntryMessage? Relayed { get; set; }
2026-05-19 17:52:29 +01:00
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Raised when a database write targets a shard this node does not own.
/// </summary>
public sealed class CultNetShardAuthorityException : InvalidOperationException
{
/// <summary>
/// Creates a shard authority exception.
/// </summary>
public CultNetShardAuthorityException(CultNetShardDescriptor shard, string message, string reason = "not_primary") : base(message)
2026-05-19 10:44:00 +01:00
{
Shard = shard;
Reason = reason;
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// Gets the shard that rejected the write.
/// </summary>
public CultNetShardDescriptor Shard { get; }
/// <summary>
/// Gets the machine-readable authority rejection reason.
/// </summary>
public string Reason { get; }
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// One shard-log sequence that a primary minted for a committed change and burned because it could not log it.
/// </summary>
public readonly struct CultNetBurnedSequence : IEquatable<CultNetBurnedSequence>
{
/// <summary>
/// Creates a burned sequence.
/// </summary>
public CultNetBurnedSequence(string shardId, long sequence)
{
ShardId = shardId;
Sequence = sequence;
}
/// <summary>
/// Gets the shard whose log burned the sequence.
/// </summary>
public string ShardId { get; }
/// <summary>
/// Gets the burned shard-log sequence.
/// </summary>
public long Sequence { get; }
/// <inheritdoc />
public bool Equals(CultNetBurnedSequence other) => Sequence == other.Sequence && string.Equals(ShardId, other.ShardId, StringComparison.Ordinal);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is CultNetBurnedSequence other && Equals(other);
/// <inheritdoc />
public override int GetHashCode() => (StringComparer.Ordinal.GetHashCode(ShardId ?? string.Empty) * 397) ^ Sequence.GetHashCode();
/// <inheritdoc />
public override string ToString() => $"{ShardId}:{Sequence}";
}
/// <summary>
/// Raised when a database write, or a resolution, names a schema and key that no configured shard owns. A refused write
/// caches nothing.
/// </summary>
public sealed class CultNetUnownedSchemaException : InvalidOperationException
{
/// <summary>
/// Creates an unowned schema exception.
/// </summary>
public CultNetUnownedSchemaException(string schemaId, CultRecordKey key)
: base($"No shard owns schema '{schemaId}' key '{key.Value}'; the write was refused.")
{
SchemaId = schemaId;
Key = key;
}
/// <summary>
/// Gets the schema that no shard owns.
/// </summary>
public string SchemaId { get; }
/// <summary>
/// Gets the key of the refused write.
/// </summary>
public CultRecordKey Key { get; }
}
/// <summary>
/// Raised to the writer when a shard primary committed changes and could not log them. The commits stand and the
/// changes were published; each shard's log burned the sequences in <see cref="Burned"/> and compacted past them,
/// so every replica behind one resynchronizes from a snapshot. A single journal call raises one of these carrying
/// every burned sequence, whatever number of changes or shards failed. It is deliberately not an
/// <see cref="InvalidOperationException"/>: callers that read that type as a refused write must not mistake a
/// committed one for it.
/// </summary>
/// <remarks>
/// The cache may still wrap this in an <see cref="AggregateException"/> when something else failed in the same hold
/// (an observer, a second database's journal). A writer that wants the log failure catches this type, or unwraps
/// <see cref="AggregateException.InnerExceptions"/> and looks for it. <see cref="Exception.InnerException"/> is the
/// store's failure, or an <see cref="AggregateException"/> of them when several changes failed.
/// </remarks>
public sealed class CultNetShardLogException : Exception
{
/// <summary>
/// Creates a shard log exception.
/// </summary>
public CultNetShardLogException(IReadOnlyList<CultNetBurnedSequence> burned, Exception cause)
: base($"Committed changes could not be logged; sequences {string.Join(", ", burned.Select(entry => $"{entry.ShardId}:{entry.Sequence}"))} are burned and compacted past.", cause)
{
Burned = burned;
}
/// <summary>
/// Gets every sequence burned by the journal call, in commit order.
/// </summary>
public IReadOnlyList<CultNetBurnedSequence> Burned { get; }
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Database-style CultNet facade over a CultCache shard set.
/// </summary>
public sealed class CultNetDatabase : IDisposable
{
private readonly CultCache _cache;
private readonly CultNetDocumentRegistry _documents;
private readonly string _runtimeId;
2026-05-19 10:44:00 +01:00
private readonly List<CultNetShardDescriptor> _shards;
private readonly List<CultNetClientAuthorityScope> _clientAuthorityScopes;
2026-05-19 19:55:29 +01:00
private readonly ICultNetShardMutationLogStore? _mutationLogStore;
// Keyed by sequence, like the durable store: recording an entry twice replaces it.
private readonly Dictionary<string, SortedList<long, CultNetShardMutationLogEntry>> _mutationLogs =
2026-05-19 17:52:29 +01:00
new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
// The highest sequence this primary burned per shard (a change it committed and could not log). Serves as the
// shard's compaction floor beside the store's own, so history the log cannot serve is answered compacted_history.
private readonly Dictionary<string, long> _burnedThrough = new(StringComparer.Ordinal);
2026-05-19 18:05:51 +01:00
private readonly Dictionary<string, long> _appliedShardSequences = new(StringComparer.Ordinal);
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
// CultNet typed selection, section 2: "ordinal is the sequence of the commit that last wrote
// the row". Kept on every append (LogPrimaryChange and RecordReplicatedEntry are the sites), keyed by (schemaId,
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
// recordKey) so the evaluator's order and cursor never read a clock.
private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence =
new();
2026-05-19 10:44:00 +01:00
private readonly Subject<object> _changes = new();
private readonly IDisposable _cacheChanges;
private readonly IDisposable _cacheJournal;
// What a write door of this database knows about the change it is admitting and the cache does not: the wire
// message it carries, a prediction it reconciles, the replicated log entry it applies. The journal is the only
// logger and consumes the context under the cache gate; a door registers it under the identity of the change it
// admits (the instance it writes, or the instance it removes), and the journal takes it once, for that change
// alone. A write to the same key by anyone else is a different instance and gets no context.
private readonly ConcurrentDictionary<object, DoorContext> _doors = new(ReferenceIdentity.Instance);
// One gate over the mutation log, its sequence counters, the last-write map and the publication stash. Lock order:
// the cache gate, then this, then the log store's own lock. Nothing holding this calls the cache, and a log store
// must not either (ICultNetShardMutationLogStore). Publication stays outside it: a subscriber may write the cache
// from its handler.
private readonly object _logGate = new();
// Changes the journal has decided and the Watch observer has not yet published, by cache Sequence. The journal
// runs under the cache gate before any observer, so each change is stashed before it can be published.
private readonly Dictionary<long, Publication> _stash = new();
// Predictions this runtime made and has not seen reconciled. Only the journal reads or writes it, under the cache gate.
private readonly HashSet<string> _predictedDocuments = new(StringComparer.Ordinal);
private volatile bool _disposed;
2026-05-19 10:44:00 +01:00
/// <summary>
/// Creates a database surface over a CultCache instance.
/// </summary>
public CultNetDatabase(CultCache cache, CultNetDatabaseOptions? options = null)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
options ??= new CultNetDatabaseOptions();
_runtimeId = options.RuntimeId;
2026-05-19 10:44:00 +01:00
_documents = options.DocumentRegistry ?? new CultNetDocumentRegistry(cache.Registry);
2026-05-19 19:55:29 +01:00
_mutationLogStore = options.MutationLogStore;
2026-05-19 10:44:00 +01:00
_shards = (options.Shards == null || options.Shards.Count == 0
? new[] { CultNetShardDescriptor.PrimaryAll(options.RuntimeId) }
: options.Shards)
.ToList();
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
2026-05-19 19:55:29 +01:00
InitializeLogSequencesFromStore();
// The observer first, the journal second: a change the journal stashed always finds its publisher.
_cacheChanges = _cache.Watch<object>().Subscribe(Publish);
_cacheJournal = _cache.AddJournal(Journal);
2026-05-19 10:44:00 +01:00
}
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
/// <summary>
/// This process's cursor key (R-O): random per instance unless <see cref="CultNetDatabaseOptions.CursorKey"/>
/// supplies one, so a page minted by this database cannot be answered by a differently-keyed one,
/// and a fresh <see cref="CultNetDatabase"/> - a real process restart, or a simulated one in tests -
/// mints under a fresh key that a prior cursor's digest does not verify against.
/// </summary>
public CultNetSelectionCursorKey CursorKey { get; }
2026-05-19 10:44:00 +01:00
/// <summary>
/// Gets the local cache backing this database surface.
/// </summary>
public CultCache Cache => _cache;
/// <summary>
/// Gets the document registry used by the raw CultNet lane.
/// </summary>
public CultNetDocumentRegistry Documents => _documents;
/// <summary>
/// Gets the shard map known to this database surface.
/// </summary>
public IReadOnlyList<CultNetShardDescriptor> Shards => _shards;
/// <summary>
/// Gets the document scopes this runtime may predict before authoritative commit.
/// </summary>
public IReadOnlyList<CultNetClientAuthorityScope> ClientAuthorityScopes => _clientAuthorityScopes;
/// <summary>Gets whether this database instance can authoritatively replace the document at a key.</summary>
public bool CanWriteAuthoritatively<T>(CultRecordKey key) where T : class
{
ThrowIfDisposed();
var descriptor = _cache.Registry.GetRequired<T>();
return FindShard(descriptor, key)?.IsPrimary ?? false;
}
/// <summary>Gets whether this database instance can submit a prediction for the document at a key.</summary>
public bool CanSubmitPrediction<T>(CultRecordKey key) where T : class
{
ThrowIfDisposed();
var descriptor = _cache.Registry.GetRequired<T>();
return _clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key));
}
2026-05-19 17:52:29 +01:00
/// <summary>
/// Gets mutation log entries for a shard after the supplied sequence.
/// </summary>
public IReadOnlyList<CultNetShardMutationLogEntry> GetMutationLog(
string shardId,
long afterSequence = 0,
int? limit = null)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
lock (_logGate)
2026-05-19 17:52:29 +01:00
{
if (!_mutationLogs.TryGetValue(shardId, out var entries))
{
return Array.Empty<CultNetShardMutationLogEntry>();
}
2026-05-19 17:52:29 +01:00
var query = entries.Values.Where(entry => entry.Sequence > afterSequence);
if (limit.HasValue)
{
query = query.Take(limit.Value);
}
2026-05-19 17:52:29 +01:00
return query.ToArray();
}
2026-05-19 17:52:29 +01:00
}
2026-05-19 19:55:29 +01:00
/// <summary>
/// Gets replica catch-up log entries for a shard after the supplied sequence.
/// </summary>
public IReadOnlyList<CultNetShardLogEntryMessage> GetMutationLogMessages(
string shardId,
long afterSequence = 0,
int? limit = null)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
if (_mutationLogStore != null)
{
return _mutationLogStore.Read(shardId, afterSequence, limit);
}
return GetMutationLog(shardId, afterSequence, limit)
.Select(ToLogEntryMessage)
.ToArray();
}
2026-05-19 20:53:45 +01:00
/// <summary>
/// Gets the highest retained-log sequence that has been compacted away for a shard.
/// </summary>
public long GetCompactedMutationLogSequence(string shardId)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
lock (_logGate)
return CompactedFloor(shardId);
}
// The store's compaction point or this primary's own burned sequences, whichever is higher. Under _logGate.
private long CompactedFloor(string shardId)
{
var burned = _burnedThrough.TryGetValue(shardId, out var through) ? through : 0;
return Math.Max(burned, _mutationLogStore?.GetCompactedThrough(shardId) ?? 0);
2026-05-19 20:53:45 +01:00
}
/// <summary>
/// Gets the latest known shard-log sequence for a shard.
/// </summary>
public long GetLatestMutationLogSequence(string shardId)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
lock (_logGate)
{
var retained = _mutationLogStore != null
? _mutationLogStore.Read(shardId).Select(entry => entry.Sequence)
: GetMutationLog(shardId).Select(entry => entry.Sequence);
return retained.Append(CompactedFloor(shardId)).Max();
}
}
/// <summary>
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>
2026-09-22 06:12:49 +02:00
/// 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,
string messageId,
CultNetSnapshotRequestMessage? filter = null)
{
ThrowIfDisposed();
if (shard == null) throw new ArgumentNullException(nameof(shard));
// The log sequence is read before any document: a write that lands in between is then replayed from the log on
// top of documents that already hold it (whole-document puts and deletes, applied in order), and a snapshot can
// never claim a sequence whose write its documents lack.
var logSequence = GetLatestMutationLogSequence(shard.ShardId);
var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds);
var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
// R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not
// reach the v1 door, which refuses an empty selection.schemas/keys outright.
if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 })
{
return new CultNetSnapshotResponseRawMessage
{
MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId,
Documents = Array.Empty<CultNetRawDocumentRecord>(),
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
ShardLogSequence = logSequence
};
}
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>
2026-09-22 06:12:49 +02:00
var selection = new CultNetSelection
{
Schemas = lowSchemas,
Keys = lowKeys,
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
Projection = CultNetSelectionProjections.Document
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>
2026-09-22 06:12:49 +02:00
};
bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) =>
!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key);
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
// R-G: one evaluation answers the whole shard snapshot, not a loop that re-filters and
// re-sorts the shard's row set once per 200-row page.
var page = _documents.SelectAll(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options: null, rowFilter: RowFilter);
return new CultNetSnapshotResponseRawMessage
{
MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId,
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
Documents = page.Documents ?? Array.Empty<CultNetRawDocumentRecord>(),
ShardId = shard.ShardId,
ShardEpoch = shard.Epoch,
ShardLogSequence = logSequence
};
}
/// <summary>
/// Applies a shard-bounded snapshot and advances the replica cursor to its represented sequence.
/// </summary>
public async Task<long> ApplyShardSnapshotResponseAsync(
CultNetShardDescriptor shard,
CultNetSnapshotResponseRawMessage response)
{
ThrowIfDisposed();
if (shard == null) throw new ArgumentNullException(nameof(shard));
if (response == null) throw new ArgumentNullException(nameof(response));
if (!string.IsNullOrWhiteSpace(response.ShardId) &&
!string.Equals(response.ShardId, shard.ShardId, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Snapshot for shard '{response.ShardId}' cannot be applied to shard '{shard.ShardId}'.");
}
if (response.ShardEpoch.HasValue && response.ShardEpoch.Value != shard.Epoch)
{
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not snapshot epoch {response.ShardEpoch.Value}.",
"stale_epoch");
}
var incomingKeys = response.Documents
.Where(document => ShardMatchesRawDocument(shard, document))
.Select(document => document.RecordKey)
.ToHashSet(StringComparer.Ordinal);
var local = GetLocalShardDocuments(shard).ToArray();
foreach (var existing in local.Where(item => !incomingKeys.Contains(item.Key.Value)))
{
using (Door(existing.Document, new DoorContext(shard) { NoLog = true, Removal = true }))
_cache.Remove(existing.Key);
}
foreach (var document in response.Documents.Where(document => ShardMatchesRawDocument(shard, document)))
{
var key = new CultRecordKey(document.RecordKey);
var descriptor = _documents.ResolveDescriptorForRawDocument(document);
var applied = _documents.DeserializeRawDocument(document);
using (Door(applied, new DoorContext(shard) { NoLog = true }))
await _cache.UpsertAsync(descriptor.DocumentType, applied, key).ConfigureAwait(false);
}
var sequence = response.ShardLogSequence ?? 0;
SetAppliedShardSequence(shard.ShardId, sequence);
return sequence;
}
2026-05-19 18:05:51 +01:00
/// <summary>
/// Gets the last replicated sequence applied for a shard.
/// </summary>
public long GetAppliedShardSequence(string shardId)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
return _appliedShardSequences.TryGetValue(shardId, out var sequence)
? sequence
: 0;
}
2026-05-19 18:19:09 +01:00
internal void SetAppliedShardSequence(string shardId, long sequence)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
if (sequence < 0) throw new ArgumentOutOfRangeException(nameof(sequence), "Sequence must be non-negative.");
_appliedShardSequences[shardId] = sequence;
}
2026-05-19 17:34:49 +01:00
/// <summary>
/// Creates a shard catalog response for optional schema/key filters.
/// </summary>
public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request)
{
ThrowIfDisposed();
if (request == null) throw new ArgumentNullException(nameof(request));
var schemaIds = request.SchemaIds == null || request.SchemaIds.Length == 0
? null
: request.SchemaIds;
var recordKeys = request.RecordKeys == null || request.RecordKeys.Length == 0
? null
: request.RecordKeys.Select(key => new CultRecordKey(key)).ToArray();
var descriptors = _shards
.Where(shard => MatchesCatalogFilter(shard, schemaIds, recordKeys))
.Select(ToMessage)
.ToArray();
return new CultNetShardCatalogResponseMessage
{
MessageId = string.IsNullOrWhiteSpace(request.MessageId)
? Guid.NewGuid().ToString("N")
: request.MessageId,
Shards = descriptors
};
}
/// <summary>
/// Resolves the shard that governs the supplied schema and key.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No configured shard owns the schema and key.</exception>
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key)
{
ThrowIfDisposed();
return ResolveShardInternal(schemaId, key);
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Gets a document by key.
/// </summary>
public Task<T?> GetAsync<T>(CultRecordKey key) where T : class
{
ThrowIfDisposed();
var document = _cache.Get<T>(key);
return Task.FromResult(document != null && Owns(_cache.Registry.GetRequired(document.GetType()), key) ? document : null);
2026-05-19 10:44:00 +01:00
}
2026-06-27 15:09:36 +01:00
/// <summary>
/// Gets all documents assignable to the requested type. A database serves only what its shards own: a cached row no
/// shard owns is left out here, as it is from every read and projection. The cache itself still holds it.
2026-06-27 15:09:36 +01:00
/// </summary>
public IEnumerable<T> GetAll<T>() where T : class
{
ThrowIfDisposed();
return _cache.GetAll<T>().Where(Serves);
2026-06-27 15:09:36 +01:00
}
/// <summary>
/// Gets a typed document by its CultName value.
/// </summary>
public T? GetByName<T>(string name) where T : class
{
ThrowIfDisposed();
var document = _cache.GetByName<T>(name);
return document != null && Serves(document) ? document : null;
2026-06-27 15:09:36 +01:00
}
/// <summary>
/// Gets a typed document by an indexed value.
/// </summary>
public T? GetByIndex<T>(string alias, string value) where T : class
{
ThrowIfDisposed();
var document = _cache.GetByIndex<T>(alias, value);
return document != null && Serves(document) ? document : null;
2026-06-27 15:09:36 +01:00
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Adds or replaces a document at a specific key.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
2026-05-19 10:44:00 +01:00
public async Task<CultRecordHandle<T>> PutAsync<T>(CultRecordKey key, T document) where T : class
{
ThrowIfDisposed();
if (document == null) throw new ArgumentNullException(nameof(document));
Route each document type to one home store; attach hydrates; commit explicit batches Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1 and 2.2). One commit, because routing, the batch that replaces the ambient transaction, and conditional commit share one admission path. Deleted first: - globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument constructor, the initializeGlobals plumbing in CultCacheMessagePack, ContainsDurableRecord (cache and directory store) - replication: the push-to-every-store loops and the attach-time push of existing records - typeof(T) as the schema owner in AddAsync/UpsertAsync and in CultNetDatabase.PutAsync/PutPredictedAsync - the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction, CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim gate, and every ambient check - CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...)) - DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248), so no v4 manifest can reach it - CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient, called it by reflection and now calls Remove(key) Added: - AddBackingStore(store, params Type[] homes): attach validates (clean, not already attached, one untyped store, no claimed home type, no admitted record changes home) and then pulls under the gate - Home(type): most derived routed home, else the untyped store - Admit: every add and remove, from a write, a batch or a load, validates (home, read-only, loaded-from-home, global singleton), lands durably, then applies and publishes; a pull hands its whole delta over in one call, so a refused record leaves the store and the cache unchanged - read-only stores (CacheBackingStore(readOnly), both store constructors, CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores - assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one candidate throws naming them - Commit(Action<CultCacheBatch>) returning bool, TryCommit returning CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged, sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch evaluates conditions under the store's exclusive lock - single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean store commits onto the file as it is, so records it never loaded survive - storedAt minted in one place and bumped a tick when not later than the record it replaces Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the Studio bridge stops reflecting on it; BackingStoreTests' global test creates its global explicitly and its transaction tests are rewritten against Commit (the two that proved ambient hydration rules are deleted); the pre-Cut 2 refusal test expects the attach to throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
var descriptor = _cache.Registry.GetRequired(document.GetType());
EnsurePrimary(ResolveShardInternal(descriptor, key), descriptor.SchemaId, key);
2026-05-19 10:44:00 +01:00
return await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
2026-05-19 10:44:00 +01:00
}
/// <summary>
/// Applies a locally predicted document for a client-owned input scope.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
public async Task<CultRecordHandle<T>> PutPredictedAsync<T>(CultRecordKey key, T document) where T : class
{
ThrowIfDisposed();
if (document == null) throw new ArgumentNullException(nameof(document));
Route each document type to one home store; attach hydrates; commit explicit batches Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1 and 2.2). One commit, because routing, the batch that replaces the ambient transaction, and conditional commit share one admission path. Deleted first: - globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument constructor, the initializeGlobals plumbing in CultCacheMessagePack, ContainsDurableRecord (cache and directory store) - replication: the push-to-every-store loops and the attach-time push of existing records - typeof(T) as the schema owner in AddAsync/UpsertAsync and in CultNetDatabase.PutAsync/PutPredictedAsync - the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction, CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim gate, and every ambient check - CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...)) - DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248), so no v4 manifest can reach it - CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient, called it by reflection and now calls Remove(key) Added: - AddBackingStore(store, params Type[] homes): attach validates (clean, not already attached, one untyped store, no claimed home type, no admitted record changes home) and then pulls under the gate - Home(type): most derived routed home, else the untyped store - Admit: every add and remove, from a write, a batch or a load, validates (home, read-only, loaded-from-home, global singleton), lands durably, then applies and publishes; a pull hands its whole delta over in one call, so a refused record leaves the store and the cache unchanged - read-only stores (CacheBackingStore(readOnly), both store constructors, CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores - assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one candidate throws naming them - Commit(Action<CultCacheBatch>) returning bool, TryCommit returning CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged, sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch evaluates conditions under the store's exclusive lock - single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean store commits onto the file as it is, so records it never loaded survive - storedAt minted in one place and bumped a tick when not later than the record it replaces Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the Studio bridge stops reflecting on it; BackingStoreTests' global test creates its global explicitly and its transaction tests are rewritten against Commit (the two that proved ambient hydration rules are deleted); the pre-Cut 2 refusal test expects the attach to throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
var descriptor = _cache.Registry.GetRequired(document.GetType());
var shard = ResolveShardInternal(descriptor, key);
EnsureClientAuthority(descriptor, key);
CultRecordHandle<T> handle;
using (Door(document, new DoorContext(shard)
{
Kind = CultNetDatabaseChangeKind.Predicted,
NoLog = true,
PredictionKey = PredictionKey(descriptor.SchemaId, key),
Predicts = true
}))
handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
return handle;
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Deletes a document by key.
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
2026-05-19 10:44:00 +01:00
public Task DeleteAsync<T>(CultRecordKey key) where T : class
{
ThrowIfDisposed();
var descriptor = _cache.Registry.GetRequired<T>();
EnsurePrimary(ResolveShardInternal(descriptor, key), descriptor.SchemaId, key);
2026-05-19 10:44:00 +01:00
_cache.Remove(new CultRecordHandle<T>(key));
2026-05-19 10:44:00 +01:00
return Task.CompletedTask;
}
/// <summary>
/// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's):
/// it asks no remote write authority. A peer's put goes through <see cref="CultNetDatabaseServer"/>, which does.
2026-05-19 10:44:00 +01:00
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
public Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message) => ApplyPutAsync(message, authorize: null);
// The peer door: the same apply, with the server's authorize callback asked once the schema is resolved and before the
// shard is. The callback throws to refuse. The public overload is the host's own door and asks no remote authority.
internal async Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message, Action<string, CultRecordKey>? authorize)
2026-05-19 10:44:00 +01:00
{
ThrowIfDisposed();
if (message == null) throw new ArgumentNullException(nameof(message));
if (message.Document == null)
{
throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message));
}
var key = new CultRecordKey(message.Document.RecordKey);
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
authorize?.Invoke(descriptor.SchemaId, key);
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
var document = _documents.DeserializeRawDocument(message.Document);
using (Door(document, new DoorContext(shard) { Put = message, PredictionKey = PredictionKey(descriptor.SchemaId, key) }))
await _cache.UpsertAsync(descriptor.DocumentType, document, key).ConfigureAwait(false);
2026-05-19 10:44:00 +01:00
return document;
}
/// <summary>
/// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote
/// write authority. A peer's delete goes through <see cref="CultNetDatabaseServer"/>, which does.
2026-05-19 10:44:00 +01:00
/// </summary>
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) => ApplyDeleteAsync(message, authorize: null);
// The peer door; see the put overload.
internal Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message, Action<string, CultRecordKey>? authorize)
2026-05-19 10:44:00 +01:00
{
ThrowIfDisposed();
if (message == null) throw new ArgumentNullException(nameof(message));
var key = new CultRecordKey(message.RecordKey);
2026-06-27 22:07:31 +01:00
var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId);
authorize?.Invoke(descriptor.SchemaId, key);
2026-06-27 22:07:31 +01:00
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
2026-05-19 10:44:00 +01:00
var previous = _cache.Get(key);
if (previous == null)
{
return Task.CompletedTask;
}
using (Door(previous, new DoorContext(shard) { Delete = message, Removal = true }))
_cache.Remove(key);
2026-05-19 10:44:00 +01:00
return Task.CompletedTask;
}
2026-05-19 18:05:51 +01:00
/// <summary>
/// Applies committed shard-log entries from an authoritative peer.
/// </summary>
public async Task<long> ApplyShardLogResponseAsync(CultNetShardLogResponseMessage response)
{
ThrowIfDisposed();
if (response == null) throw new ArgumentNullException(nameof(response));
if (response.ResyncRequired)
{
throw new InvalidOperationException(
$"Shard '{response.ShardId}' requires snapshot resync before log application: {response.Reason ?? "unspecified"}.");
}
var shard = _shards.FirstOrDefault(candidate =>
string.Equals(candidate.ShardId, response.ShardId, StringComparison.Ordinal));
if (shard == null)
{
throw new InvalidOperationException($"Shard '{response.ShardId}' is not known by this database.");
}
if (response.ShardEpoch != shard.Epoch)
{
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not response epoch {response.ShardEpoch}.",
"stale_epoch");
}
var lastApplied = GetAppliedShardSequence(response.ShardId);
var orderedEntries = response.Entries.OrderBy(entry => entry.Sequence).ToArray();
foreach (var entry in orderedEntries)
{
if (entry.Sequence <= lastApplied)
{
continue;
}
if (entry.Sequence != lastApplied + 1)
{
throw new InvalidOperationException(
$"Shard '{response.ShardId}' log has a gap. Expected sequence {lastApplied + 1}, received {entry.Sequence}.");
}
await ApplyCommittedShardLogEntryAsync(shard, entry).ConfigureAwait(false);
lastApplied = entry.Sequence;
_appliedShardSequences[response.ShardId] = lastApplied;
}
return lastApplied;
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Watches all changes assignable to the requested document type.
/// </summary>
public Observable<CultNetDatabaseChange<T>> Watch<T>() where T : class
{
ThrowIfDisposed();
return _changes
.Where(change => change is CultNetDatabaseChange<T>)
.Select(change => (CultNetDatabaseChange<T>)change);
}
2026-05-19 17:10:24 +01:00
/// <summary>
/// Watches all typed domain changes as boxed change objects.
/// </summary>
public Observable<object> WatchAllChanges()
{
ThrowIfDisposed();
return _changes;
}
2026-05-19 10:44:00 +01:00
/// <summary>
/// Watches one record.
/// </summary>
public Observable<CultNetDatabaseChange<T>> WatchRecord<T>(CultRecordKey key) where T : class
{
return Watch<T>().Where(change => change.Key.Equals(key));
}
/// <summary>
/// Watches the global record for a document type.
/// </summary>
public Observable<CultNetDatabaseChange<T>> WatchGlobal<T>() where T : class
{
var descriptor = _cache.Registry.GetRequired<T>();
return WatchRecord<T>(new CultRecordKey($"global:{descriptor.SchemaId}"));
}
/// <summary>
/// Watches changes for a named document.
/// </summary>
public Observable<CultNetDatabaseChange<T>> WatchByName<T>(string name) where T : class
{
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
return Watch<T>().Where(change =>
(change.Document != null && ReferenceEquals(_cache.GetByName<T>(name), change.Document)) ||
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByName<T>(name), change.PreviousDocument)));
}
/// <summary>
/// Watches changes for the current document mapped by an indexed value.
/// </summary>
public Observable<CultNetDatabaseChange<T>> WatchByIndex<T>(string alias, string value) where T : class
{
if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias));
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value));
return Watch<T>().Where(change =>
(change.Document != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.Document)) ||
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.PreviousDocument)));
}
/// <summary>
/// Releases database subscriptions.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// The journal first: disposing it waits for a journal in flight, so nothing is stashed after this line. What a
// writer has logged and not yet published is then published here, so a change the log holds is never dropped.
_cacheJournal.Dispose();
_cacheChanges.Dispose();
Publication[] unpublished;
lock (_logGate)
{
unpublished = _stash.OrderBy(entry => entry.Key).Select(entry => entry.Value).ToArray();
_stash.Clear();
}
foreach (var publication in unpublished)
{
Publish(publication);
}
2026-05-19 10:44:00 +01:00
_changes.Dispose();
}
// The cache is the one owner of "a change was committed"; the journal is this database's one decision point and its
// one logger. It runs under the cache gate, in cache Sequence order, before any observer: it takes the change's door,
// settles the change kind and the prediction bookkeeping, logs the change when this runtime is the shard's primary
// (a replica records only the primary's own entry, which its door carries), and stashes what the publisher needs.
// A change with no door was written by someone else (a bare cache write, a load, a pull) and is logged like any
// unattributed commit. It never enters the cache and calls nothing that does.
private void Journal(IReadOnlyList<CultCacheDocumentChange<object>> changes)
2026-05-19 10:44:00 +01:00
{
if (_disposed)
{
return;
}
List<Exception>? failures = null;
List<CultNetBurnedSequence>? burned = null;
List<Exception>? burnCauses = null;
foreach (var change in changes)
{
var identity = change.Document ?? change.PreviousDocument;
if (identity == null)
{
continue;
}
var door = TakeDoor(change);
var documentType = identity.GetType();
var descriptor = _cache.Registry.GetRequired(documentType);
var shard = door?.Shard ?? FindShard(descriptor, change.Key);
if (shard == null)
{
// No shard owns a bare cache write of this schema: the database neither logs nor publishes it.
continue;
}
var kind = door?.Kind ?? change.Kind switch
{
CultCacheDocumentChangeKind.Removed => CultNetDatabaseChangeKind.Removed,
CultCacheDocumentChangeKind.Added => CultNetDatabaseChangeKind.Added,
_ => CultNetDatabaseChangeKind.Updated
};
if (door?.PredictionKey is { } predictionKey)
{
if (door.Predicts)
_predictedDocuments.Add(predictionKey);
else if (_predictedDocuments.Remove(predictionKey))
kind = CultNetDatabaseChangeKind.Reconciled;
}
// The log records a reconciled change as the update it is.
var logKind = kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind;
try
{
if (door?.Replica is { } replicated)
{
RecordReplicatedEntry(shard, logKind, descriptor.SchemaId, change, replicated);
}
else if (door?.NoLog != true && shard.IsPrimary)
{
if (LogPrimaryChange(shard, logKind, descriptor.SchemaId, change, door) is { } burn)
{
(burned ??= new List<CultNetBurnedSequence>()).Add(new CultNetBurnedSequence(shard.ShardId, burn.Sequence));
(burnCauses ??= new List<Exception>()).Add(burn.Cause);
}
}
}
catch (Exception exception)
{
(failures ??= new List<Exception>()).Add(exception);
}
lock (_logGate)
_stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument);
}
// Every change is stashed and will be published; the writer hears of a failed log afterwards, in one exception.
if (burned != null)
{
(failures ??= new List<Exception>()).Insert(
0,
new CultNetShardLogException(burned, burnCauses!.Count == 1 ? burnCauses[0] : new AggregateException(burnCauses)));
}
if (failures != null)
{
throw failures.Count == 1 ? failures[0] : new AggregateException(failures);
}
}
// The Watch observer publishes, on the writer's thread after the cache gate opens and before the write returns, what
// the journal stashed for this change. A change with no stash was admitted before this database existed.
private void Publish(CultCacheDocumentChange<object> change)
{
Publication? publication;
lock (_logGate)
2026-05-19 10:44:00 +01:00
{
if (!_stash.Remove(change.Sequence, out publication))
{
return;
}
2026-05-19 10:44:00 +01:00
}
Publish(publication);
}
private void Publish(Publication publication) =>
PublishUntyped(
publication.DocumentType,
publication.Kind,
publication.Key,
publication.SchemaId,
publication.Shard,
publication.Document,
publication.PreviousDocument);
private void RecordReplicatedEntry(
CultNetShardDescriptor shard,
CultNetDatabaseChangeKind logKind,
string schemaId,
CultCacheDocumentChange<object> change,
CultNetShardLogEntryMessage replicated)
{
lock (_logGate)
{
RecordMutationLogEntry(new CultNetShardMutationLogEntry(
shard.ShardId,
shard.Epoch,
replicated.Sequence,
replicated.CommittedAt,
logKind,
schemaId,
change.Key,
change.Document,
change.PreviousDocument),
replicated);
_lastWriteSequence[(schemaId, change.Key.Value)] = replicated.Sequence;
}
}
// A primary mints the sequence, appends durably, then records in memory. If it cannot log the change, the change is
// still committed: the sequence is burned and compacted past, so a replica behind it is told compacted_history and
// resynchronizes by snapshot. The burn is returned for the journal to tell the writer in one CultNetShardLogException.
private (long Sequence, Exception Cause)? LogPrimaryChange(
CultNetShardDescriptor shard,
CultNetDatabaseChangeKind logKind,
string schemaId,
CultCacheDocumentChange<object> change,
DoorContext? door)
{
lock (_logGate)
{
var sequence = NextMutationLogSequence(shard.ShardId);
_lastWriteSequence[(schemaId, change.Key.Value)] = sequence;
try
{
var entry = new CultNetShardMutationLogEntry(
shard.ShardId,
shard.Epoch,
sequence,
DateTimeOffset.UtcNow.ToString("O"),
logKind,
schemaId,
change.Key,
change.Document,
change.PreviousDocument);
// A door's own wire message is what the log stores (its kind, sequence and shard are restated from the entry).
var wireEntry = _mutationLogStore == null
? null
: door is { Put: not null } or { Delete: not null }
? new CultNetShardLogEntryMessage { Put = door.Put, Delete = door.Delete }
: ToLogEntryMessage(entry);
RecordMutationLogEntry(entry, wireEntry);
return null;
}
catch (Exception cause)
{
_burnedThrough[shard.ShardId] = Math.Max(_burnedThrough.TryGetValue(shard.ShardId, out var burned) ? burned : 0, sequence);
try
{
_mutationLogStore?.CompactThrough(shard.ShardId, sequence);
}
catch (Exception compactCause)
{
cause = new AggregateException(cause, compactCause);
}
return (sequence, cause);
}
}
}
// A door's context belongs to one specific change, recognised by shape: a put door's change carries the instance as
// its new document; a removal door removes an instance, so its change carries it as the previous document and no
// new one. A plain removal of an instance a put door admitted is neither, and gets no context. A door's own change
// is journaled first, in its own hold, and takes its context there.
private DoorContext? TakeDoor(CultCacheDocumentChange<object> change)
{
var identity = change.Document ?? change.PreviousDocument;
if (identity == null || !_doors.TryGetValue(identity, out var door))
{
return null;
}
var matches = change.Document == null
? door.Removal
: !door.Removal;
return matches && ((ICollection<KeyValuePair<object, DoorContext>>)_doors)
.Remove(new KeyValuePair<object, DoorContext>(identity, door))
? door
: null;
}
private DoorRegistration Door(object identity, DoorContext context) =>
new(_doors, identity, context, _doors.TryAdd(identity, context));
// Cleanup only: a door whose change never arrived (a refused write) must not leave its context behind.
private readonly struct DoorRegistration : IDisposable
{
private readonly ConcurrentDictionary<object, DoorContext> _doors;
private readonly object _identity;
private readonly DoorContext _context;
private readonly bool _owned;
public DoorRegistration(ConcurrentDictionary<object, DoorContext> doors, object identity, DoorContext context, bool owned)
{
_doors = doors;
_identity = identity;
_context = context;
_owned = owned;
}
// True when the journal took the context: the door's write was admitted.
public bool Consumed => !_owned || !_doors.TryGetValue(_identity, out var held) || !ReferenceEquals(held, _context);
public void Dispose()
{
if (_owned)
((ICollection<KeyValuePair<object, DoorContext>>)_doors).Remove(new KeyValuePair<object, DoorContext>(_identity, _context));
}
}
// What the publisher needs of a change the journal decided.
private sealed class Publication
{
public Publication(
Type documentType,
CultNetDatabaseChangeKind kind,
CultRecordKey key,
string schemaId,
CultNetShardDescriptor shard,
object? document,
object? previousDocument)
{
DocumentType = documentType;
Kind = kind;
Key = key;
SchemaId = schemaId;
Shard = shard;
Document = document;
PreviousDocument = previousDocument;
}
public Type DocumentType { get; }
public CultNetDatabaseChangeKind Kind { get; }
public CultRecordKey Key { get; }
public string SchemaId { get; }
public CultNetShardDescriptor Shard { get; }
public object? Document { get; }
public object? PreviousDocument { get; }
}
private sealed class DoorContext
{
public DoorContext(CultNetShardDescriptor shard) => Shard = shard;
public CultNetShardDescriptor Shard { get; }
public CultNetDatabaseChangeKind? Kind { get; set; }
public bool NoLog { get; set; }
public bool Removal { get; set; }
public CultNetDocumentPutRawMessage? Put { get; set; }
public CultNetDocumentDeleteMessage? Delete { get; set; }
public CultNetShardLogEntryMessage? Replica { get; set; }
// The prediction this door's change either records (Predicts) or ends.
public string? PredictionKey { get; set; }
public bool Predicts { get; set; }
}
private sealed class ReferenceIdentity : IEqualityComparer<object>
{
public static readonly ReferenceIdentity Instance = new();
bool IEqualityComparer<object>.Equals(object? x, object? y) => ReferenceEquals(x, y);
int IEqualityComparer<object>.GetHashCode(object value) => RuntimeHelpers.GetHashCode(value);
2026-05-19 10:44:00 +01:00
}
private CultRecordKey GetTrackedKey(object document, Type documentType)
{
var method = typeof(CultCache).GetMethod(nameof(CultCache.TryGetHandle))!
.MakeGenericMethod(documentType);
var handleObject = method.Invoke(_cache, new[] { document });
if (handleObject == null)
{
return new CultRecordKey(string.Empty);
}
var keyProperty = handleObject.GetType().GetProperty("Key");
return keyProperty?.GetValue(handleObject) is CultRecordKey key
? key
: new CultRecordKey(string.Empty);
}
private IEnumerable<TrackedShardDocument> GetLocalShardDocuments(CultNetShardDescriptor shard)
{
foreach (var document in _cache.AllEntries)
{
var documentType = document.GetType();
var descriptor = _cache.Registry.GetRequired(documentType);
var key = GetTrackedKey(document, documentType);
if (!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor, key))
{
yield return new TrackedShardDocument(documentType, descriptor.SchemaId, key, document);
}
}
}
private bool ShardMatchesRawDocument(CultNetShardDescriptor shard, CultNetRawDocumentRecord document)
{
if (document == null ||
string.IsNullOrWhiteSpace(document.SchemaId) ||
string.IsNullOrWhiteSpace(document.RecordKey))
{
return false;
}
try
{
var descriptor = _documents.ResolveDescriptorForRawDocument(document);
return shard.Matches(descriptor, new CultRecordKey(document.RecordKey));
}
catch (InvalidOperationException)
{
return false;
}
}
private sealed class TrackedShardDocument
{
public TrackedShardDocument(Type documentType, string schemaId, CultRecordKey key, object document)
{
DocumentType = documentType;
SchemaId = schemaId;
Key = key;
Document = document;
}
public Type DocumentType { get; }
public string SchemaId { get; }
public CultRecordKey Key { get; }
public object Document { get; }
}
2026-05-19 17:52:29 +01:00
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
/// <summary>
/// The shard-log sequence of the commit that last wrote this row, or null when the row has
/// never been committed through this database. The order the CultNet selection evaluator
/// pages by (docs/cultnet-selection-cut.md, section 2).
/// </summary>
public long? LastWriteSequence(string schemaId, CultRecordKey key)
{
lock (_logGate)
return _lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null;
}
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
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
/// <summary>
/// The current write-sequence watermark across every shard this database holds, 0 when nothing
/// has been committed yet. Only meaningful for an answer that is not scoped to any particular
/// shard's log - a selection's own <c>asOf</c> must use <see cref="CurrentAsOf(string)"/> instead
/// (S-9): the shard-log sequence is a per-shard counter (<see cref="NextMutationLogSequence"/>),
/// so taking the maximum across every shard's rows mixes counters that do not compare to one
/// another and can report a page as exact "as of" a sequence another, unrelated shard advanced to
/// while the page's own shard sat still.
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
/// </summary>
public ulong CurrentAsOf()
{
lock (_logGate)
return _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max();
}
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#) Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message listeners and Server.cs's pre-verification allowlist gains them; CultNetDatabaseSubscriptionServer's subscribe path is generalised to a shared SubscriptionRequest so v0 (lowered) and v1 selections run the same Subscribe/Watch/Reconcile core, with D6's rule wired in: a hop-bearing selection runs the full reconcile diff on every change instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives both servers a real snapshot watermark. Fixes the confirmed parity and correctness defects from Soul's whole-cut pass: - R-B: cited/cites edges are anchored to the row the direction actually reports against (the citer under cites, the cited row under cited), not filtered by "From is on the page" regardless of direction; order is page-row order then (from, role, to) in code-point order. - R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/ string.CompareOrdinal in the row tiebreak, cursor position and edge order, so an astral key sorts the same way here as it does under Rust's UTF-8 byte comparison. - R-D: CultCache renders a float/double's exact decimal expansion from its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble), never .NET's shortest round-trip form, which rounded a tie the opposite way from Rust at some values. The now-dead ExpandScientificNotation is deleted. - R-E: MatchesCitation compares a cites target through CultNetSchemaAliasMatching instead of an exact SchemaId string compare; ToEdge and ToRawRecord now emit the same wire schema id (WireSchemaId) for the same row, where ToEdge previously used the descriptor's own id and ToRawRecord the binding's override. - R-F: CultNetSelectionEvaluator.Select validates first, unconditionally - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse is gone, so no public evaluation path can skip the door. Mesh's EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape (the declaration-independent half of the door) before its own v0 transport refusals, so Keys=[]/[""] is refused there too. - R-G: Evaluation.TotalMatched is the selection's whole matching count; SelectPage's Matched field now reports it instead of the page's row count. CreateRawSnapshotResponse and CreateShardSnapshotResponse (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll, which evaluates once via CultNetSelectionEvaluator.EvaluateAll and projects every matched row, instead of looping SelectPage per 200-row page and re-filtering/re-sorting the whole row set each time. - R-H: the cursor digest is length-prefixed for every string and list (AppendString/AppendList) instead of delimiter-joined, closing the collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"]. - R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the string getter's culture-dependent ToString(). - R-M: WithBindingSchemaAlias is deleted; both servers' single-change fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases instead, the one binding-alias reconciler. The duplicated v0/shard lower-and-page loop is gone with SelectAll above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
/// <summary>
/// The write-sequence watermark of one shard's own log (S-9): the sequence of the last commit
/// that shard's log recorded, or 0 when the shard has never taken a write. This is the evaluator's
/// <c>asOf</c> for a v1 snapshot or subscription page whose matched rows all come from one shard
/// (<see cref="CultNetDocumentRegistry"/>'s single-shard check) - it advances only when that
/// shard's own log advances, so a cursor minted against it refuses <c>cursor_stale</c> exactly
/// when that shard's log has moved, never because an unrelated shard elsewhere took a write the
/// selection never touched.
/// </summary>
public ulong CurrentAsOf(string shardId)
{
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
lock (_logGate)
return _nextLogSequences.TryGetValue(shardId, out var next) ? (ulong)(next - 1) : 0UL;
}
2026-05-19 19:55:29 +01:00
private void RecordMutationLogEntry(
CultNetShardMutationLogEntry entry,
CultNetShardLogEntryMessage? wireEntry = null)
2026-05-19 18:05:51 +01:00
{
if (!_mutationLogs.TryGetValue(entry.ShardId, out var entries))
2026-05-19 17:52:29 +01:00
{
entries = new SortedList<long, CultNetShardMutationLogEntry>();
2026-05-19 18:05:51 +01:00
_mutationLogs[entry.ShardId] = entries;
2026-05-19 17:52:29 +01:00
}
// Disk first: memory never serves an entry the durable log lacks, and a refused append records nothing.
if (_mutationLogStore != null && wireEntry != null)
2026-05-19 18:05:51 +01:00
{
_mutationLogStore.Append(entry.ShardId, NormalizeWireLogEntry(entry, wireEntry));
2026-05-19 18:05:51 +01:00
}
2026-05-19 19:55:29 +01:00
entries[entry.Sequence] = entry;
if (!_nextLogSequences.TryGetValue(entry.ShardId, out var next) ||
next <= entry.Sequence)
2026-05-19 19:55:29 +01:00
{
_nextLogSequences[entry.ShardId] = entry.Sequence + 1;
2026-05-19 19:55:29 +01:00
}
2026-05-19 18:05:51 +01:00
}
private long NextMutationLogSequence(string shardId)
{
var sequence = _nextLogSequences.TryGetValue(shardId, out var next)
? next
: 1;
_nextLogSequences[shardId] = sequence + 1;
return sequence;
}
2026-05-19 19:55:29 +01:00
private void InitializeLogSequencesFromStore()
{
if (_mutationLogStore == null)
{
return;
}
foreach (var shard in _shards)
{
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
// R-Q: the log is the durable record of every commit, replica-applied ones included, so
// the last-write sequence a restart resumes with is rebuilt from it exactly as
// LogPrimaryChange/the replica apply paths maintain it live - a row's ordinal for
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
// the selection evaluator's order and cursor must not reset to "never written" on restart.
var entries = _mutationLogStore.Read(shard.ShardId).OrderBy(entry => entry.Sequence).ToArray();
var compacted = _mutationLogStore.GetCompactedThrough(shard.ShardId);
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
var highest = 0L;
var expected = compacted + 1;
var hole = 0L;
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
foreach (var entry in entries)
{
// A sequence the log lacks between two it holds was burned (its append and its compaction both failed, or
// a compaction was interrupted). Replicas must be told compacted_history for it, not served a gapped log.
if (entry.Sequence > compacted && entry.Sequence > expected)
{
hole = entry.Sequence - 1;
}
expected = Math.Max(expected, entry.Sequence + 1);
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
if (entry.Sequence > highest)
{
highest = entry.Sequence;
}
var (schemaId, recordKey) = KeyOf(entry);
if (schemaId != null && recordKey != null)
{
_lastWriteSequence[(schemaId, recordKey)] = entry.Sequence;
}
}
// Sequences at or below the compaction point are spent even when the log holds none of them.
highest = Math.Max(highest, compacted);
if (hole > 0)
{
_burnedThrough[shard.ShardId] = hole;
}
2026-05-19 19:55:29 +01:00
if (highest > 0)
{
_nextLogSequences[shard.ShardId] = highest + 1;
}
}
}
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
private static (string? SchemaId, string? RecordKey) KeyOf(CultNetShardLogEntryMessage entry)
{
if (entry.Put?.Document != null)
{
return (entry.Put.Document.SchemaId, entry.Put.Document.RecordKey);
}
if (entry.Delete != null)
{
return (entry.Delete.SchemaId, entry.Delete.RecordKey);
}
return (null, null);
}
2026-05-19 18:05:51 +01:00
private async Task ApplyCommittedShardLogEntryAsync(
CultNetShardDescriptor shard,
CultNetShardLogEntryMessage entry)
{
if (entry.Put != null)
{
await ApplyCommittedPutAsync(shard, entry).ConfigureAwait(false);
return;
}
if (entry.Delete != null)
{
ApplyCommittedDelete(shard, entry);
return;
}
throw new InvalidOperationException(
$"Shard '{shard.ShardId}' log entry {entry.Sequence} has no put or delete payload.");
}
private async Task ApplyCommittedPutAsync(
CultNetShardDescriptor shard,
CultNetShardLogEntryMessage entry)
{
var message = entry.Put!;
if (message.Document == null)
{
throw new InvalidOperationException(
$"Shard '{shard.ShardId}' log entry {entry.Sequence} put is missing its document payload.");
}
if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) ||
message.ShardEpoch != shard.Epoch)
{
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.",
"stale_epoch");
}
var key = new CultRecordKey(message.Document.RecordKey);
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
2026-05-19 18:05:51 +01:00
var kind = ChangeKindFromWire(entry.ChangeKind);
if (kind == CultNetDatabaseChangeKind.Removed)
{
throw new InvalidOperationException(
$"Shard '{shard.ShardId}' log entry {entry.Sequence} has a removed kind with a put payload.");
}
// The replica's log records the primary's own entry under the primary's own sequence, in the same hold that
// admits the change; the journal does both. A retry after a refused append is a fresh instance, so it matches
// its door again and re-records the entry.
if (!shard.Matches(descriptor, key))
{
RecordUnappliedEntry(shard, kind, descriptor.SchemaId, key, entry);
return;
}
var document = _documents.DeserializeRawDocument(message.Document);
using (Door(document, new DoorContext(shard) { Kind = kind, Replica = entry, PredictionKey = PredictionKey(descriptor.SchemaId, key) }))
await _cache.UpsertAsync(descriptor.DocumentType, document, key).ConfigureAwait(false);
2026-05-19 18:05:51 +01:00
}
private void ApplyCommittedDelete(
CultNetShardDescriptor shard,
CultNetShardLogEntryMessage entry)
{
var message = entry.Delete!;
if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) ||
message.ShardEpoch != shard.Epoch)
{
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.",
"stale_epoch");
}
var key = new CultRecordKey(message.RecordKey);
var descriptor = _cache.Registry.GetRequiredBySchemaId(message.SchemaId);
if (!shard.Matches(descriptor, key))
{
RecordUnappliedEntry(shard, CultNetDatabaseChangeKind.Removed, descriptor.SchemaId, key, entry);
return;
}
var admitted = false;
2026-05-19 18:05:51 +01:00
var previous = _cache.Get(key);
if (previous != null)
{
using var door = Door(previous, new DoorContext(shard) { Kind = CultNetDatabaseChangeKind.Removed, Replica = entry, Removal = true });
_cache.Remove(key);
admitted = door.Consumed;
}
if (!admitted)
{
// The key is not here, so nothing was admitted to log.
RecordUnappliedEntry(shard, CultNetDatabaseChangeKind.Removed, descriptor.SchemaId, key, entry);
}
}
// An entry that changed nothing here (an absent key, or a row this shard does not own - the snapshot path leaves such
// rows out too). This replica's log must still hold every entry of the primary's, or a replica chained on it sees a gap.
private void RecordUnappliedEntry(
CultNetShardDescriptor shard,
CultNetDatabaseChangeKind kind,
string schemaId,
CultRecordKey key,
CultNetShardLogEntryMessage entry)
{
lock (_logGate)
{
RecordMutationLogEntry(new CultNetShardMutationLogEntry(
shard.ShardId, shard.Epoch, entry.Sequence, entry.CommittedAt, kind,
schemaId, key, document: null, previousDocument: null) { Relayed = entry }, entry);
_lastWriteSequence[(schemaId, key.Value)] = entry.Sequence;
2026-05-19 18:05:51 +01:00
}
}
2026-05-19 19:55:29 +01:00
private CultNetShardLogEntryMessage ToLogEntryMessage(CultNetShardMutationLogEntry entry)
{
if (entry.Relayed != null)
{
return NormalizeWireLogEntry(entry, entry.Relayed);
}
2026-05-19 19:55:29 +01:00
if (entry.Kind == CultNetDatabaseChangeKind.Removed || entry.Document == null)
{
return new CultNetShardLogEntryMessage
{
Sequence = entry.Sequence,
CommittedAt = entry.CommittedAt,
ChangeKind = "removed",
Delete = new CultNetDocumentDeleteMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SchemaId = entry.SchemaId,
RecordKey = entry.Key.Value,
ShardId = entry.ShardId,
ShardEpoch = entry.ShardEpoch
}
};
}
return new CultNetShardLogEntryMessage
{
Sequence = entry.Sequence,
CommittedAt = entry.CommittedAt,
ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
Put = new CultNetDocumentPutRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
Document = CreateRawDocumentRecord(entry.Key, entry.Document),
ShardId = entry.ShardId,
ShardEpoch = entry.ShardEpoch
}
};
}
private CultNetShardLogEntryMessage NormalizeWireLogEntry(
CultNetShardMutationLogEntry entry,
CultNetShardLogEntryMessage wireEntry)
{
return new CultNetShardLogEntryMessage
{
Sequence = entry.Sequence,
CommittedAt = string.IsNullOrWhiteSpace(entry.CommittedAt) ? wireEntry.CommittedAt : entry.CommittedAt,
ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Removed
? "removed"
: entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
Put = wireEntry.Put == null
? null
: new CultNetDocumentPutRawMessage
{
MessageId = string.IsNullOrWhiteSpace(wireEntry.Put.MessageId)
? Guid.NewGuid().ToString("N")
: wireEntry.Put.MessageId,
Document = wireEntry.Put.Document,
ShardId = entry.ShardId,
ShardEpoch = entry.ShardEpoch
},
Delete = wireEntry.Delete == null
? null
: new CultNetDocumentDeleteMessage
{
MessageId = string.IsNullOrWhiteSpace(wireEntry.Delete.MessageId)
? Guid.NewGuid().ToString("N")
: wireEntry.Delete.MessageId,
SchemaId = wireEntry.Delete.SchemaId,
RecordKey = wireEntry.Delete.RecordKey,
ShardId = entry.ShardId,
ShardEpoch = entry.ShardEpoch
}
};
}
private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document)
{
var method = typeof(CultNetDocumentRegistry)
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) &&
candidate.IsGenericMethodDefinition);
var documentType = document.GetType();
var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType);
var handle = Activator.CreateInstance(handleType, new object[] { key });
var message = method
.MakeGenericMethod(documentType)
.Invoke(_documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null });
return ((CultNetDocumentPutRawMessage)message!).Document;
}
2026-05-19 18:05:51 +01:00
private static CultNetDatabaseChangeKind ChangeKindFromWire(string changeKind)
{
return changeKind switch
{
"added" => CultNetDatabaseChangeKind.Added,
"updated" => CultNetDatabaseChangeKind.Updated,
"removed" => CultNetDatabaseChangeKind.Removed,
_ => throw new InvalidOperationException($"Unsupported shard log change kind '{changeKind}'.")
};
2026-05-19 17:52:29 +01:00
}
private void EnsureClientAuthority(CultDocumentDescriptor descriptor, CultRecordKey key)
{
if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key)))
{
return;
}
var shard = ResolveShardInternal(descriptor, key);
throw new CultNetShardAuthorityException(
shard,
$"Runtime '{_runtimeId}' does not have client prediction authority for schema '{descriptor.SchemaId}' key '{key.Value}'.",
"not_client_authority");
}
private static string PredictionKey(string schemaId, CultRecordKey key)
{
return $"{schemaId}:{key.Value}";
}
2026-05-19 10:44:00 +01:00
private void PublishUntyped(
Type documentType,
CultNetDatabaseChangeKind kind,
CultRecordKey key,
string schemaId,
CultNetShardDescriptor shard,
object? document,
object? previousDocument)
{
var changeType = typeof(CultNetDatabaseChange<>).MakeGenericType(documentType);
var change = Activator.CreateInstance(
changeType,
kind,
key,
schemaId,
shard,
document,
previousDocument,
null);
if (change != null)
{
_changes.OnNext(change);
}
}
// No shard owns a schema and key that no descriptor matches, and nothing is written to one: every write path refuses.
private CultNetShardDescriptor ResolveShardInternal(string schemaId, CultRecordKey key) =>
_shards.FirstOrDefault(shard => shard.Matches(schemaId, key)) ?? throw new CultNetUnownedSchemaException(schemaId, key);
2026-05-19 10:44:00 +01:00
private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key) =>
FindShard(descriptor, key) ?? throw new CultNetUnownedSchemaException(descriptor.SchemaId, key);
internal bool Owns(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) != null;
// Whether the database serves this cached instance: a shard owns its schema and key. A shard that owns everything makes
// the lookup of the instance's key unnecessary.
private bool Serves(object document)
{
if (_shards.Any(shard => shard.SchemaIds.Count == 0 && string.IsNullOrEmpty(shard.KeyPrefix)))
{
return true;
}
var type = document.GetType();
return Owns(_cache.Registry.GetRequired(type), GetTrackedKey(document, type));
}
private CultNetShardDescriptor? FindShard(CultDocumentDescriptor descriptor, CultRecordKey key) =>
_shards.FirstOrDefault(shard => shard.Matches(descriptor, key));
2026-05-19 17:34:49 +01:00
private static void EnsurePrimary(CultNetShardDescriptor shard, string schemaId, CultRecordKey key, long? expectedEpoch = null)
2026-05-19 10:44:00 +01:00
{
2026-05-19 17:34:49 +01:00
if (expectedEpoch.HasValue && expectedEpoch.Value != shard.Epoch)
{
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not requested epoch {expectedEpoch.Value}.",
"stale_epoch");
2026-05-19 17:34:49 +01:00
}
2026-05-19 10:44:00 +01:00
if (shard.IsPrimary)
{
return;
}
throw new CultNetShardAuthorityException(
shard,
$"Shard '{shard.ShardId}' owned by '{shard.OwnerRuntimeId}' does not accept local writes for schema '{schemaId}' key '{key.Value}'.",
"not_primary");
2026-05-19 10:44:00 +01:00
}
private bool MatchesCatalogFilter(
2026-05-19 17:34:49 +01:00
CultNetShardDescriptor shard,
IReadOnlyList<string>? schemaIds,
IReadOnlyList<CultRecordKey>? recordKeys)
{
var schemaMatches = SchemaMatchesCatalogFilter(shard, schemaIds);
2026-05-19 17:34:49 +01:00
var keyMatches = recordKeys == null ||
recordKeys.Count == 0 ||
recordKeys.Any(key => string.IsNullOrEmpty(shard.KeyPrefix) ||
key.Value.StartsWith(shard.KeyPrefix!, StringComparison.Ordinal));
return schemaMatches && keyMatches;
}
private bool SchemaMatchesCatalogFilter(
CultNetShardDescriptor shard,
IReadOnlyList<string>? schemaIds)
{
if (schemaIds == null || schemaIds.Count == 0 || shard.SchemaIds.Count == 0)
return true;
var requestedSchemaIds = schemaIds.ToHashSet(StringComparer.Ordinal);
if (shard.SchemaIds.Any(requestedSchemaIds.Contains))
return true;
foreach (var shardSchemaId in shard.SchemaIds)
{
try
{
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher _projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production caller and ran after authorization with no wire consumer. Removed the field, constructor parameter, and both call sites in Watch() and CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a delegate. The two tests that used projectRecord to simulate a record's identity changing for the reconcile diff (DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords, …ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten to drive a real rename through the database (delete the old key, put the new one) instead, so they still prove authorization filtering and the Reconcile() diff/body-demand behavior. Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one matcher landed in commit 1. Networking tests: 162/162 (was 162/162 after commit 1; no count change, two tests rewritten not added). Negative grep: _projectRecord|projectRecord empty; MatchesRequestedSchema gone (one remaining substring hit is RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this commit does not touch — collapses in the next commit). Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs, CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this commit; it is large enough to build on its own and follows as commit 2b. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
if (CultNetSchemaAliasMatching.MatchesAny(schemaIds, _cache.Registry.GetRequiredBySchemaId(shardSchemaId)))
return true;
}
catch (InvalidOperationException)
{
// Shards can advertise schemas that are not loaded in this process.
}
}
return false;
}
2026-05-19 17:34:49 +01:00
internal static CultNetShardDescriptorMessage ToMessage(CultNetShardDescriptor shard)
{
return new CultNetShardDescriptorMessage
{
ShardId = shard.ShardId,
OwnerRuntimeId = shard.OwnerRuntimeId,
Epoch = shard.Epoch,
IsPrimary = shard.IsPrimary,
SchemaIds = shard.SchemaIds.ToArray(),
KeyPrefix = shard.KeyPrefix,
PrimaryEndpoints = shard.PrimaryEndpoints.ToArray(),
ReplicaEndpoints = shard.ReplicaEndpoints.ToArray(),
ReadReplicaEndpoints = shard.ReadReplicaEndpoints.ToArray(),
Region = shard.Region
};
}
2026-05-19 10:44:00 +01:00
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(CultNetDatabase));
}
}
}
}