diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs
index d5b3773d..57f221f8 100644
--- a/src/GameCult.Networking/CultNetDatabase.cs
+++ b/src/GameCult.Networking/CultNetDatabase.cs
@@ -305,6 +305,12 @@ namespace GameCult.Networking
/// Gets or sets the durable store for accepted shard mutation logs.
///
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
+ ///
+ /// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key
+ /// per 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.
+ ///
+ public CultNetSelectionCursorKey? CursorKey { get; set; }
}
///
@@ -494,10 +500,25 @@ namespace GameCult.Networking
: options.Shards)
.ToList();
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty()).ToList();
+ CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
InitializeLogSequencesFromStore();
_cache.OnUpdate += PublishCacheUpdate;
}
+ ///
+ /// This process's cursor key (R-O): random per instance unless
+ /// supplies one, so a page minted by this database cannot be answered by a differently-keyed one,
+ /// and a fresh - 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.
+ ///
+ public CultNetSelectionCursorKey CursorKey { get; }
+
+ ///
+ /// 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 asOf is well defined).
+ ///
+ public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) => ResolveShardInternal(schemaId, key);
+
///
/// Gets the local cache backing this database surface.
///
@@ -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,
diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs
index 3c6f89db..1e53abf1 100644
--- a/src/GameCult.Networking/CultNetDatabaseServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseServer.cs
@@ -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);
}
///
diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs
index 1cf0a13c..dd85ffec 100644
--- a/src/GameCult.Networking/CultNetDocumentRegistry.cs
+++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs
@@ -344,13 +344,15 @@ namespace GameCult.Networking
CultNetSelection selection,
Func ordinalOf,
ulong asOf,
- CultNetDocumentMessageOptions? options = null)
+ CultNetDocumentMessageOptions? options = null,
+ Func? 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 ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options,
- Func? rowFilter = null)
+ Func? rowFilter = null,
+ Func? 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
};
}
+ ///
+ /// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q):
+ /// asOf is one shard-log watermark ( 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
+ /// keys list (R-F), rather than answered with an asOf that is not really defined -
+ /// reusing /selection_invalid (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.
+ ///
+ private static void EnsureSingleShard(
+ IReadOnlyList rows,
+ Func 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.");
+ }
+ }
+ }
+
///
/// 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
diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs
index bc2b2379..f343fad7 100644
--- a/src/GameCult.Networking/CultNetSchemaMessages.cs
+++ b/src/GameCult.Networking/CultNetSchemaMessages.cs
@@ -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"
};
}
diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs
index aaeeacec..0e01ec0e 100644
--- a/src/GameCult.Networking/CultNetSelectionEvaluator.cs
+++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs
@@ -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 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);
}
///
@@ -232,17 +233,25 @@ namespace GameCult.Networking
/// v0/shard full walk calls this once per 200-row chunk of a single
/// result instead of re-evaluating per page.
///
- public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf)
+ ///
+ /// 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
+ /// still gets a keyed digest; 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.
+ ///
+ 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
}
///
- /// 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. is never persisted or serialized; the answering server
+ /// holds one instance for its process lifetime (), 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 cursor_invalid - acceptable, because cursors are
+ /// short-lived and section 2 already calls them "minted by the answering server".
+ ///
+ public sealed class CultNetSelectionCursorKey
+ {
+ ///
+ /// The key /
+ /// 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
+ /// (most unit tests) still gets a keyed, per-process digest instead
+ /// of an unkeyed one.
+ ///
+ 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;
+ }
+
+ /// A fresh 32-byte key from the OS RNG.
+ public static CultNetSelectionCursorKey Random()
+ {
+ var bytes = new byte[32];
+ RandomNumberGenerator.Fill(bytes);
+ return new CultNetSelectionCursorKey(bytes);
+ }
+
+ internal byte[] KeyBytes => _key;
+ }
+
+ ///
+ /// 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
+ /// (R-O). Minted only by the answering server.
///
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]);
}
///
- /// 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 under
+ /// in fixed time (R-O): a cursor forged without the key, or minted under a different process's
+ /// key (a restart), does not verify.
+ ///
+ public bool VerifyDigest(CultNetSelection selection, CultNetSelectionCursorKey key)
+ {
+ var expected = ComputeDigest(selection, key);
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(Digest),
+ Encoding.UTF8.GetBytes(expected));
+ }
+
+ ///
+ /// An HMAC-SHA256 digest, keyed under , 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"].
///
- 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);
+ }
+
+ ///
+ /// Reads exactly length-prefixed fields written by
+ /// back out of , 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.
+ ///
+ private static List? ReadLengthPrefixedFields(byte[] bytes, int count)
+ {
+ var fields = new List(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;
+ }
}
///
diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs
index 9e96d0e3..b2dfcf3c 100644
--- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs
+++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs
@@ -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().SchemaId },
+ Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
+ };
+
+ var ex = Assert.Throws(() => 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)]
diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs
index 20e1b015..04401aba 100644
--- a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs
+++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs
@@ -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(
+ () => 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(
+ () => 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.
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 55a7d28d..5ab0d13b 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -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(
+ sourceCache.Registry,
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+ var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
+ .Register(CultNetDocumentBinding.ForDocument(
+ targetCache.Registry,
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+ var schemaId = sourceCache.Registry.GetRequired().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(key);
+ var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
+ {
+ ShardId = "players-replica-delete",
+ ShardEpoch = 1
+ });
+
+ await targetDatabase.ApplyShardLogResponseAsync(response);
+
+ Assert.That(targetCache.Get(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().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(
+ 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(
+ cache.Registry,
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+ var schemaId = cache.Registry.GetRequired().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(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
+ {
+ Selection = new CultNetSelection { Schemas = [schemaId] }
+ }));
+ Assert.That(ex!.Field, Is.EqualTo("asOf"));
}
[Test]