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>
This commit is contained in:
Meta Vens 2026-09-22 20:04:22 +02:00
commit a4cf54f4a9
8 changed files with 541 additions and 41 deletions

View file

@ -305,6 +305,12 @@ namespace GameCult.Networking
/// Gets or sets the durable store for accepted shard mutation logs.
/// </summary>
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
/// <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; }
}
/// <summary>
@ -494,10 +500,25 @@ namespace GameCult.Networking
: options.Shards)
.ToList();
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
InitializeLogSequencesFromStore();
_cache.OnUpdate += PublishCacheUpdate;
}
/// <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; }
/// <summary>
/// Resolves the shard that owns a record key (R-Q: whether a matched selection's rows span more
/// than one shard's log, and so whether the page's <c>asOf</c> is well defined).
/// </summary>
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) => ResolveShardInternal(schemaId, key);
/// <summary>
/// Gets the local cache backing this database surface.
/// </summary>
@ -1337,10 +1358,26 @@ namespace GameCult.Networking
foreach (var shard in _shards)
{
var highest = _mutationLogStore.Read(shard.ShardId)
.Select(entry => entry.Sequence)
.DefaultIfEmpty(0)
.Max();
// 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
// AppendMutationLogEntry/the replica apply paths maintain it live - a row's ordinal for
// the selection evaluator's order and cursor must not reset to "never written" on restart.
var entries = _mutationLogStore.Read(shard.ShardId);
var highest = 0L;
foreach (var entry in entries)
{
if (entry.Sequence > highest)
{
highest = entry.Sequence;
}
var (schemaId, recordKey) = KeyOf(entry);
if (schemaId != null && recordKey != null)
{
_lastWriteSequence[(schemaId, recordKey)] = entry.Sequence;
}
}
if (highest > 0)
{
_nextLogSequences[shard.ShardId] = highest + 1;
@ -1348,6 +1385,21 @@ namespace GameCult.Networking
}
}
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);
}
private async Task ApplyCommittedShardLogEntryAsync(
CultNetShardDescriptor shard,
CultNetShardLogEntryMessage entry)
@ -1416,6 +1468,11 @@ namespace GameCult.Networking
document,
previous),
entry);
// R-Q: a replica apply is a commit path too - the last-write sequence this row now carries
// is the entry's own sequence, exactly like AppendMutationLogEntry's local-commit path sets
// it, so the selection evaluator's order and cursor agree whether a row's most recent write
// landed locally or arrived through replication.
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
PublishUntyped(
descriptor.DocumentType,
kind,
@ -1459,6 +1516,8 @@ namespace GameCult.Networking
document: null,
previousDocument: previous),
entry);
// R-Q: see ApplyCommittedPutAsync - a replica-applied delete is a commit too.
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
PublishUntyped(
descriptor.DocumentType,
CultNetDatabaseChangeKind.Removed,

View file

@ -130,7 +130,12 @@ namespace GameCult.Networking
string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId,
request.Selection,
ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0,
asOf: _database.CurrentAsOf());
asOf: _database.CurrentAsOf(),
// R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than
// one shard's log is refused rather than answered against a watermark that is not
// exact for all of them.
shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId,
cursorKey: _database.CursorKey);
}
/// <summary>

View file

@ -344,13 +344,15 @@ namespace GameCult.Networking
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options = null)
CultNetDocumentMessageOptions? options = null,
Func<string, CultRecordKey, string>? shardIdOf = null,
CultNetSelectionCursorKey? cursorKey = null)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
if (selection == null) throw new ArgumentNullException(nameof(selection));
if (ordinalOf == null) throw new ArgumentNullException(nameof(ordinalOf));
var page = SelectPage(cache, selection, ordinalOf, asOf, options);
var page = SelectPage(cache, selection, ordinalOf, asOf, options, shardIdOf: shardIdOf, cursorKey: cursorKey);
return new CultNetSnapshotResponseRawV1Message
{
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
@ -374,7 +376,9 @@ namespace GameCult.Networking
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options,
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null)
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null,
Func<string, CultRecordKey, string>? shardIdOf = null,
CultNetSelectionCursorKey? cursorKey = null)
{
selection = ExpandSchemaBindingAliases(selection);
// R-F: CultNetSelectionEvaluator.Select validates first, every time - there is no separate
@ -388,7 +392,17 @@ namespace GameCult.Networking
.Select(entry => new CultNetSelectionEvaluator.Row(
entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt))
.ToArray();
var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf);
var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection);
// R-Q: a page's asOf is exact only when every matched row is committed through one shard's
// log - a caller that knows about shards (CultNetDatabaseServer) supplies shardIdOf; one
// that does not (the raw v0/shard-scoped rowFilter paths, which are already bounded to a
// single shard by construction) passes null and skips the check rather than pay for it.
if (shardIdOf != null)
{
EnsureSingleShard(full.Ordered, shardIdOf);
}
var evaluation = CultNetSelectionEvaluator.Page(full, selection, asOf, cursorKey);
var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray();
var wantDocument = selection.Projection == CultNetSelectionProjections.Document;
@ -404,6 +418,38 @@ namespace GameCult.Networking
};
}
/// <summary>
/// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q):
/// <c>asOf</c> is one shard-log watermark (<see cref="CultNetDatabase.CurrentAsOf"/> is a single
/// number today, not one per shard), so a page spanning two shards' logs cannot honestly claim
/// to be exact as of it. Refused door-side, the same as an unreachable index or an empty
/// <c>keys</c> list (R-F), rather than answered with an <c>asOf</c> that is not really defined -
/// reusing <see cref="CultNetSelectionInvalidException"/>/<c>selection_invalid</c> (R-N) rather
/// than adding a second refusal shape for what is, from the wire's point of view, the same
/// "this selection cannot be answered as asked" refusal a bad field or an empty list gets.
/// </summary>
private static void EnsureSingleShard(
IReadOnlyList<CultNetSelectionEvaluator.Row> rows,
Func<string, CultRecordKey, string> shardIdOf)
{
string? shardId = null;
foreach (var row in rows)
{
var rowShardId = shardIdOf(row.Descriptor.SchemaId, row.Key);
if (shardId == null)
{
shardId = rowShardId;
}
else if (!string.Equals(shardId, rowShardId, StringComparison.Ordinal))
{
throw new CultNetSelectionInvalidException(
"asOf",
null,
$"The selection matches rows from more than one shard's log (at least '{shardId}' and '{rowShardId}'); asOf is not defined for a shard-spanning page.");
}
}
}
/// <summary>
/// Evaluates a selection over every row the cache holds and projects every matched row - no
/// cursor, no 200-row cap. The v0 lowering and the shard-bounded snapshot both answer their

View file

@ -553,7 +553,11 @@ namespace GameCult.Networking
public static CultNetErrorMessage ForReferenceOutsideTarget(CultNetSelectionReferenceOutsideTargetException ex) =>
new CultNetErrorMessage
{
Error = $"reference_outside_target: {ex.Message}",
// ex.Message already begins "reference_outside_target: " (the exception's own
// constructor prefixes it) - this used to prefix it a second time, so a peer received
// "reference_outside_target: reference_outside_target: ...". Rust's ForReferenceOutsideTarget
// (packages/cultnet-rs) emits the single-prefixed form; this now matches it.
Error = ex.Message,
Code = "reference_outside_target"
};
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@ -221,10 +221,11 @@ namespace GameCult.Networking
CultDocumentRegistry registry,
IReadOnlyList<Row> allRows,
CultNetSelection selection,
ulong asOf)
ulong asOf,
CultNetSelectionCursorKey? cursorKey = null)
{
var full = EvaluateAll(registry, allRows, selection);
return Page(full, selection, asOf);
return Page(full, selection, asOf, cursorKey);
}
/// <summary>
@ -232,17 +233,25 @@ namespace GameCult.Networking
/// v0/shard full walk calls this once per 200-row chunk of a single <see cref="EvaluateAll"/>
/// result instead of re-evaluating per page.
/// </summary>
public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf)
/// <param name="cursorKey">
/// The answering process's cursor key (R-O). Defaults to a key generated once per process the
/// first time this method runs, so a caller that never sets up its own <see cref="CultNetDatabase"/>
/// still gets a keyed digest; <see cref="CultNetDatabase"/> holds and passes its own instance so a
/// fresh database (a simulated restart in tests, a real process restart in production) mints
/// under a fresh key and a prior cursor's digest stops verifying.
/// </param>
public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf, CultNetSelectionCursorKey? cursorKey = null)
{
if (full == null) throw new ArgumentNullException(nameof(full));
if (selection == null) throw new ArgumentNullException(nameof(selection));
var key = cursorKey ?? CultNetSelectionCursorKey.ProcessDefault;
var ordered = full.Ordered;
var startIndex = 0;
if (!string.IsNullOrEmpty(selection.Cursor))
{
var cursor = CultNetSelectionCursor.Parse(selection.Cursor!);
if (cursor.Digest != CultNetSelectionCursor.ComputeDigest(selection))
if (!cursor.VerifyDigest(selection, key))
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor's selection digest does not match this selection.");
if (cursor.AsOf != asOf)
throw new CultNetSelectionCursorException(
@ -257,7 +266,7 @@ namespace GameCult.Networking
var page = ordered.Skip(startIndex).Take(limit).ToArray();
var hasNext = startIndex + page.Length < ordered.Count;
var nextCursor = hasNext && page.Length > 0
? CultNetSelectionCursor.Mint(asOf, page[^1], selection)
? CultNetSelectionCursor.Mint(asOf, page[^1], selection, key)
: null;
var pageEdges = selection.HasHop
@ -439,8 +448,49 @@ namespace GameCult.Networking
}
/// <summary>
/// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and a digest of the selection
/// with cursor and limit cleared (section 2). Minted only by the answering server.
/// The answering process's key for cursor digests (R-O, F6: "cursors are keyed"). A digest is an
/// HMAC-SHA256 under this key, so a cursor cannot be forged without it and cannot be verified by
/// a different key. <see cref="Random"/> is never persisted or serialized; the answering server
/// holds one instance for its process lifetime (<see cref="CultNetDatabase.CursorKey"/>), so a
/// cursor minted before a restart carries a digest the new process's key does not produce, and
/// the restart's first page for it answers <c>cursor_invalid</c> - acceptable, because cursors are
/// short-lived and section 2 already calls them "minted by the answering server".
/// </summary>
public sealed class CultNetSelectionCursorKey
{
/// <summary>
/// The key <see cref="CultNetSelectionEvaluator.Page"/>/<see cref="CultNetSelectionEvaluator.Select"/>
/// fall back to when no caller-owned key is supplied - generated once, the first time this type
/// is touched in the process, so a caller that evaluates selections without wiring up its own
/// <see cref="CultNetDatabase"/> (most unit tests) still gets a keyed, per-process digest instead
/// of an unkeyed one.
/// </summary>
internal static readonly CultNetSelectionCursorKey ProcessDefault = Random();
private readonly byte[] _key;
public CultNetSelectionCursorKey(byte[] key)
{
if (key == null) throw new ArgumentNullException(nameof(key));
if (key.Length == 0) throw new ArgumentException("Value must be non-empty.", nameof(key));
_key = key;
}
/// <summary>A fresh 32-byte key from the OS RNG.</summary>
public static CultNetSelectionCursorKey Random()
{
var bytes = new byte[32];
RandomNumberGenerator.Fill(bytes);
return new CultNetSelectionCursorKey(bytes);
}
internal byte[] KeyBytes => _key;
}
/// <summary>
/// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and an HMAC digest of the
/// selection with cursor and limit cleared, keyed under the answering process's
/// <see cref="CultNetSelectionCursorKey"/> (R-O). Minted only by the answering server.
/// </summary>
public readonly struct CultNetSelectionCursor
{
@ -459,50 +509,67 @@ namespace GameCult.Networking
public string RecordKey { get; }
public string Digest { get; }
public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection)
public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection, CultNetSelectionCursorKey key)
{
var digest = ComputeDigest(selection);
var raw = string.Join(
"",
asOf.ToString(CultureInfo.InvariantCulture),
lastRow.Ordinal.ToString(CultureInfo.InvariantCulture),
lastRow.Descriptor.SchemaId,
lastRow.Key.Value,
digest);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));
var digest = ComputeDigest(selection, key);
// R-O: the cursor body is length-prefixed fields, not a delimiter-joined string - a record
// key carrying any character at all, including whatever delimiter an earlier scheme would
// have chosen (Soul found a key containing U+241F broke a delimiter split), round-trips.
var body = new StringBuilder();
AppendString(body, asOf.ToString(CultureInfo.InvariantCulture));
AppendString(body, lastRow.Ordinal.ToString(CultureInfo.InvariantCulture));
AppendString(body, lastRow.Descriptor.SchemaId);
AppendString(body, lastRow.Key.Value);
AppendString(body, digest);
return Base64UrlEncode(Encoding.UTF8.GetBytes(body.ToString()));
}
public static CultNetSelectionCursor Parse(string cursor)
{
string raw;
byte[] bytes;
try
{
raw = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
bytes = Base64UrlDecode(cursor);
}
catch (FormatException)
{
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
}
var parts = raw.Split('');
if (parts.Length != 5 ||
!ulong.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) ||
!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal))
var fields = ReadLengthPrefixedFields(bytes, 5);
if (fields == null ||
!ulong.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) ||
!long.TryParse(fields[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal))
{
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
}
return new CultNetSelectionCursor(asOf, ordinal, parts[2], parts[3], parts[4]);
return new CultNetSelectionCursor(asOf, ordinal, fields[2], fields[3], fields[4]);
}
/// <summary>
/// A digest of the selection with cursor and limit cleared, so a cursor is bound to the
/// selection that minted it. R-H: every string and list is length-prefixed, so no delimiter
/// choice can make two different selections collide - the prior delimiter-joined form digested
/// Verifies this cursor's digest against <paramref name="selection"/> under <paramref name="key"/>
/// in fixed time (R-O): a cursor forged without the key, or minted under a different process's
/// key (a restart), does not verify.
/// </summary>
public bool VerifyDigest(CultNetSelection selection, CultNetSelectionCursorKey key)
{
var expected = ComputeDigest(selection, key);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(Digest),
Encoding.UTF8.GetBytes(expected));
}
/// <summary>
/// An HMAC-SHA256 digest, keyed under <paramref name="key"/>, of the selection with cursor and
/// limit cleared, so a cursor is bound both to the selection that minted it and to the process
/// that minted it (R-O). R-H: every string and list is length-prefixed, so no delimiter choice
/// can make two different selections collide - a delimiter-joined form used to digest
/// values ["a|b"] the same as ["a","b"], and keys ["a,b"] the same as ["a","b"].
/// </summary>
public static string ComputeDigest(CultNetSelection selection)
public static string ComputeDigest(CultNetSelection selection, CultNetSelectionCursorKey? key = null)
{
key ??= CultNetSelectionCursorKey.ProcessDefault;
var sb = new StringBuilder();
AppendList(sb, selection.Schemas);
AppendList(sb, selection.Keys);
@ -545,8 +612,8 @@ namespace GameCult.Networking
AppendString(sb, selection.Projection);
sb.Append(selection.Descending ? '1' : '0');
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
using var hmac = new HMACSHA256(key.KeyBytes);
var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
var hex = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) hex.Append(b.ToString("x2", CultureInfo.InvariantCulture));
return hex.ToString();
@ -566,6 +633,49 @@ namespace GameCult.Networking
foreach (var value in ordered)
AppendString(sb, value);
}
private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] Base64UrlDecode(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
case 0: break;
default: throw new FormatException("Invalid base64url string length.");
}
return Convert.FromBase64String(padded);
}
/// <summary>
/// Reads exactly <paramref name="count"/> length-prefixed fields written by
/// <see cref="AppendString"/> back out of <paramref name="bytes"/>, in order. Null on any
/// malformed length, a truncated value, a non-UTF-8 value, or leftover bytes after the last
/// field - the cursor body has no field this reader may skip.
/// </summary>
private static List<string>? ReadLengthPrefixedFields(byte[] bytes, int count)
{
var fields = new List<string>(count);
var pos = 0;
for (var i = 0; i < count; i++)
{
var colon = Array.IndexOf(bytes, (byte)':', pos);
if (colon < 0) return null;
var lengthText = Encoding.UTF8.GetString(bytes, pos, colon - pos);
if (!int.TryParse(lengthText, NumberStyles.None, CultureInfo.InvariantCulture, out var length))
return null;
pos = colon + 1;
if (length < 0 || pos + length > bytes.Length) return null;
fields.Add(Encoding.UTF8.GetString(bytes, pos, length));
pos += length;
}
return pos == bytes.Length ? fields : null;
}
}
/// <summary>

View file

@ -246,6 +246,11 @@ namespace GameCult.Networking.Tests
Assert.That(decoded.Code, Is.EqualTo("reference_outside_target"));
Assert.That(decoded.Details, Is.Null);
// ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on
// top of the exception's own message, which already carries it - a peer received the code
// name twice. One prefix only, on the wire bytes a peer actually decodes.
Assert.That(decoded.Error, Does.StartWith("reference_outside_target: "));
Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:"));
}
// S6: the hop follows one declared reference by role; a second role is not followed.
@ -441,6 +446,46 @@ namespace GameCult.Networking.Tests
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" }));
}
// S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving
// mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant
// that widens it back to every registered descriptor lets a predicate on an index only an
// EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead
// of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared
// on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter.
[Test]
public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach()
{
var registry = Registry();
var descriptors = registry.AllDescriptors.ToArray();
var selection = new CultNetSelection
{
Schemas = new[] { registry.GetRequired<SelCiter>().SchemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
};
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo("fields[0].index"));
}
// S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value
// equals ANY one of the predicate's values, not when it equals every one of them - impossible
// for a single-valued index whenever Values carries more than one entry, so a mutant that
// requires all of them makes a real multi-value any_of predicate match nothing.
[Test]
public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues()
{
var registry = Registry();
var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1);
var selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } }
};
var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
}
public abstract class SelFixtureMiddle
{
[Key(0)]

View file

@ -190,6 +190,95 @@ namespace GameCult.Networking.Tests
Assert.That(CultNetSelectionCursor.ComputeDigest(valuesOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(valuesTwo)));
}
// S5-DigestNoFields (fix batch 3 "Surviving mutants"): the digest reads selection.Fields - a
// mutant that drops the fields loop would digest two selections identically as long as their
// schemas/keys/hop/projection agree, letting a cursor minted for one selection page a
// differently-fielded one.
[Test]
public void ComputeDigest_DiffersWhenOnlyFieldsDiffer()
{
var withFieldA = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a" } } } };
var withFieldB = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "b" } } } };
var noFields = new CultNetSelection();
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(withFieldB)));
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(noFields)));
}
// R-O (fix batch 3, F6 "cursors are keyed"): a cursor's digest is an HMAC under the answering
// process's key - a page request carrying a cursor minted under a different key (forged, or
// minted by a different process) is refused cursor_invalid, never answered.
[Test]
public void Page_RefusesACursorMintedUnderADifferentKey()
{
var registry = Registry();
var rows = Enumerable.Range(0, 3)
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
.ToArray();
var selection = new CultNetSelection { Limit = 1 };
var mintingKey = CultNetSelectionCursorKey.Random();
var otherKey = CultNetSelectionCursorKey.Random();
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: mintingKey);
Assert.That(first.NextCursor, Is.Not.Null);
selection.Cursor = first.NextCursor;
var ex = Assert.Throws<CultNetSelectionCursorException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: otherKey));
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
}
// R-O: a cursor minted before a (simulated) restart does not verify against the restarted
// process's fresh key - the same refusal as a forged cursor, because from the answering
// process's point of view they are indistinguishable (F6: "not surviving a restart is the
// accepted cost, not a bug").
[Test]
public void Page_RefusesACursorMintedBeforeARestart()
{
var registry = Registry();
var rows = Enumerable.Range(0, 3)
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
.ToArray();
var selection = new CultNetSelection { Limit = 1 };
var preRestartKey = CultNetSelectionCursorKey.Random();
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: preRestartKey);
Assert.That(first.NextCursor, Is.Not.Null);
// A restart is a fresh process key, exactly as CultNetDatabase's constructor mints a fresh
// CursorKey per instance unless one is supplied.
var postRestartKey = CultNetSelectionCursorKey.Random();
selection.Cursor = first.NextCursor;
var ex = Assert.Throws<CultNetSelectionCursorException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: postRestartKey));
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
}
// R-O: the cursor body is length-prefixed, not delimiter-joined, so a record key carrying any
// character at all - including U+241F, which broke the pre-fix delimiter-split parse - round-trips.
[Test]
public void Cursor_RoundTripsARecordKeyContainingU241F()
{
var registry = Registry();
var weirdKey = "row-␟-two";
var rows = new[]
{
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 0 }, "a-first", 0),
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, weirdKey, 1)
};
var selection = new CultNetSelection { Limit = 1 };
var key = CultNetSelectionCursorKey.Random();
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a-first" }));
Assert.That(first.NextCursor, Is.Not.Null);
selection.Cursor = first.NextCursor;
var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { weirdKey }));
Assert.That(second.NextCursor, Is.Null);
}
// R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the
// string getter's culture-dependent ToString() - a float large enough that .NET's default
// ToString() renders exponent notation ("1E+21") must still match its canonical decimal form.

View file

@ -5493,6 +5493,148 @@ namespace GameCult.Networking.Tests
Assert.That(replicated, Is.Not.Null);
Assert.That(replicated!.Username, Is.EqualTo("Replica"));
Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1));
// R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too -
// it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the
// ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and
// cursor treat a replicated row as never written.
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
}
[Test]
public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence()
{
var sourceCache = new CultCache();
var targetCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
targetCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().SchemaId;
CultNetShardDescriptor Shard(bool isPrimary) => new(
"players-replica-delete",
"runtime-a",
epoch: 1,
isPrimary: isPrimary,
schemaIds: [schemaId],
keyPrefix: "player:");
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
Shards = [Shard(true)]
});
var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions
{
DocumentRegistry = targetRegistry,
Shards = [Shard(false)]
});
using var server = new Server(sourceCache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase);
var key = new CultRecordKey("player:replica-delete");
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" };
await sourceDatabase.PutAsync(key, player);
await sourceDatabase.DeleteAsync<PlayerData>(key);
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
{
ShardId = "players-replica-delete",
ShardEpoch = 1
});
await targetDatabase.ApplyShardLogResponseAsync(response);
Assert.That(targetCache.Get<PlayerData>(key), Is.Null);
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2));
}
[Test]
public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart()
{
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N"));
var schemaId = new CultCache().Registry.GetRequired<PlayerData>().SchemaId;
var shard = new CultNetShardDescriptor(
"players-durable-sequence",
"runtime-a",
epoch: 1,
isPrimary: true,
schemaIds: [schemaId],
keyPrefix: "player:");
var sourceCache = new CultCache();
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
sourceCache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
{
DocumentRegistry = sourceRegistry,
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
var key = new CultRecordKey("player:durable-sequence");
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" };
await sourceDatabase.PutAsync(key, player);
Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
sourceDatabase.Dispose();
// R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the
// sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences
// (what the next local commit gets), never _lastWriteSequence (what a row's own last
// write was), so a selection evaluated right after restart would order/page the row as
// "never written" until it was written again.
var restartedCache = new CultCache();
var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions
{
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
Shards = [shard]
});
Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
}
[Test]
public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog()
{
var cache = new CultCache();
var registry = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
cache.Registry,
payloadSerializer: SerializePlayerDataPayload,
payloadDeserializer: DeserializePlayerDataPayload));
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
DocumentRegistry = registry,
Shards =
[
new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"),
new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:")
]
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
await database.PutAsync(
new CultRecordKey("a:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" });
await database.PutAsync(
new CultRecordKey("b:one"),
new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" });
// R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log
// and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than
// answered, reusing the selection_invalid door refusal (R-N) rather than a second shape.
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
Selection = new CultNetSelection { Schemas = [schemaId] }
}));
Assert.That(ex!.Field, Is.EqualTo("asOf"));
}
[Test]