using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using GameCult.Caching; using MessagePack; namespace GameCult.Networking { /// /// The four comparison and one any-of operator a field predicate may carry. Serialised on the wire /// as the op string (), never as a union: the C# /// enum exists for evaluator and validation ergonomics only. /// public enum CultNetSelectionOperator { /// Matches when the row's declared value is one of values. AnyOf, /// Matches when the row's declared numeric value is less than number. Lt, /// Matches when the row's declared numeric value is less than or equal to number. Le, /// Matches when the row's declared numeric value is greater than or equal to number. Ge, /// Matches when the row's declared numeric value is greater than number. Gt } /// Converts to and from its wire spelling. public static class CultNetSelectionOperators { /// Returns the wire spelling of an operator. public static string ToWireString(this CultNetSelectionOperator op) => op switch { CultNetSelectionOperator.AnyOf => "any_of", CultNetSelectionOperator.Lt => "lt", CultNetSelectionOperator.Le => "le", CultNetSelectionOperator.Ge => "ge", CultNetSelectionOperator.Gt => "gt", _ => throw new ArgumentOutOfRangeException(nameof(op)) }; /// Parses a wire operator string; returns false for anything else. public static bool TryParse(string? wire, out CultNetSelectionOperator op) { switch (wire) { case "any_of": op = CultNetSelectionOperator.AnyOf; return true; case "lt": op = CultNetSelectionOperator.Lt; return true; case "le": op = CultNetSelectionOperator.Le; return true; case "ge": op = CultNetSelectionOperator.Ge; return true; case "gt": op = CultNetSelectionOperator.Gt; return true; default: op = default; return false; } } /// True for the four comparison operators (everything but any_of). public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf; } /// /// The wire's canonical decimal form (Q-J, docs/cultnet-selection-cut.md section 2 "Numbers"): one /// spelling per value, so the cursor digest and the cross-runtime parity vectors need no numeric /// parser and no precision limit. A comparison number that does not match , or /// that spells zero with a leading minus, is refused at the door rather than normalised - a /// non-canonical spelling of an equal value never reaches the evaluator. /// public static class CultNetCanonicalNumber { /// /// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent /// notation, and whitespace. Does not by itself forbid "-0"; does. /// Anchored with \z, not $: .NET's $ matches immediately before a single /// trailing newline even without , /// so a naive $ here would accept "5\n" as canonical (R-D/Q-J; the JSON schema's ECMA-262 /// $ has no such exception and already refused it). /// public const string Pattern = @"\A-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?\z"; private static readonly Regex CanonicalRegex = new(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); /// True for a canonical decimal string; false for null, "-0", or any other spelling. public static bool IsCanonical(string? value) => value != null && value != "-0" && CanonicalRegex.IsMatch(value); /// /// Compares two canonical decimal strings by sign, then the integer part's length, then its /// digits, then the fraction digits padded on the right to equal length - a pure string /// comparison, never a numeric parse, so no CLR type's range limits what a comparison can hold. /// Callers must validate both operands with first; this does not /// re-validate. /// public static int Compare(string left, string right) { var (negativeLeft, integerLeft, fractionLeft) = Decompose(left); var (negativeRight, integerRight, fractionRight) = Decompose(right); if (negativeLeft != negativeRight) return negativeLeft ? -1 : 1; var sign = negativeLeft ? -1 : 1; if (integerLeft.Length != integerRight.Length) return sign * (integerLeft.Length < integerRight.Length ? -1 : 1); var integerCompare = string.CompareOrdinal(integerLeft, integerRight); if (integerCompare != 0) return sign * Math.Sign(integerCompare); var fractionLength = Math.Max(fractionLeft.Length, fractionRight.Length); var fractionCompare = string.CompareOrdinal( fractionLeft.PadRight(fractionLength, '0'), fractionRight.PadRight(fractionLength, '0')); return sign * Math.Sign(fractionCompare); } private static (bool Negative, string Integer, string Fraction) Decompose(string canonical) { var negative = canonical.Length > 0 && canonical[0] == '-'; var unsigned = negative ? canonical.Substring(1) : canonical; var dot = unsigned.IndexOf('.'); return dot < 0 ? (negative, unsigned, string.Empty) : (negative, unsigned.Substring(0, dot), unsigned.Substring(dot + 1)); } } /// One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2). [MessagePackObject] public sealed class CultNetFieldPredicate { /// A declared index alias, reachable on some schema the selection can reach. [Key("index")] public string Index { get; set; } = string.Empty; /// "any_of", "lt", "le", "ge", or "gt". [Key("op")] public string Op { get; set; } = string.Empty; /// Present iff op = any_of; non-empty. [Key("values")] public string[]? Values { get; set; } /// Present iff op is a comparison; a canonical decimal string (Q-J, ). [Key("number")] public string? Number { get; set; } } /// Identifies one row on the wire by schema and record key. [MessagePackObject] public sealed class CultNetRecordRef { /// Gets or sets the schema id. [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; /// Gets or sets the record key. [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; } /// Selects rows whose declared reference names a target row (docs/cultnet-selection-cut.md, section 2). [MessagePackObject] public sealed class CultNetCitation { /// The row the citer must name. [Key("target")] public CultNetRecordRef Target { get; set; } = new(); /// The reference's declared alias; absent matches any declared reference. [Key("role")] public string? Role { get; set; } } /// Selects rows some other row does or does not name in role - the one negation. [MessagePackObject] public sealed class CultNetIncoming { /// The declared reference alias to check for an incoming edge. [Key("role")] public string Role { get; set; } = string.Empty; /// True to select rows with an incoming edge, false to select rows without one. [Key("exists")] public bool Exists { get; set; } } /// /// One typed selection, carried by cultnet.snapshot_request.v1 and /// cultnet.database_subscribe.v1. docs/cultnet-selection-cut.md, section 2. /// [MessagePackObject] public sealed class CultNetSelection { /// Kinds: schema ids or aliases, matched by . Absent = every schema. [Key("schemas")] public string[]? Schemas { get; set; } /// Record-key allowlist. Absent = every key. [Key("keys")] public string[]? Keys { get; set; } /// Conjunction; each is any-of over one declared index alias. [Key("fields")] public CultNetFieldPredicate[]? Fields { get; set; } /// Rows whose declared reference names target. [Key("cites")] public CultNetCitation? Cites { get; set; } /// Rows some row does or does not name in role. [Key("cited")] public CultNetIncoming? Cited { get; set; } /// "header" or "document"; default "header". [Key("projection")] public string Projection { get; set; } = CultNetSelectionProjections.Header; /// Reverses the one order. [Key("descending")] public bool Descending { get; set; } /// Clamped 1..=200. [Key("limit")] public uint? Limit { get; set; } /// Opaque; minted by the answering server. [Key("cursor")] public string? Cursor { get; set; } /// True when this selection follows an edge (section 2: "Edges in the answer"). [IgnoreMember] public bool HasHop => Cites != null || Cited != null; } /// /// Lowers a v0 schemaIds/recordKeys filter into cultnet.selection.v1's null-means- /// every vocabulary (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0's old cleaning /// carried over as the one place that decides it. An absent list, an empty one, or one made only /// of blank entries all lower to null; a mixed list keeps its non-blank entries, deduplicated. /// This is a v0-compatibility rule, not a meaning or /// carries - v1 selections are refused at the door /// instead (S1). /// public static class CultNetV0SelectionLowering { /// /// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a null list means /// "no filter" and stays null; a non-null list, even one that filters down to nothing, /// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was /// wrong - the pre-cut reference (CultNetDatabase.CreateShardSnapshotResponse at /// b3d9cf7) tests filter?.RecordKeys != null, not a length, so /// recordKeys: [] always answered empty. The evaluator already honours that distinction /// (CultNetSelectionEvaluator: a non-null Keys array excludes every row when it /// is empty); only this lowering used to erase it before the selection ever reached the /// evaluator. /// public static string[]? Lower(IReadOnlyList? values) { if (values == null) return null; return values .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.Ordinal) .ToArray(); } } /// The two wire values may carry. public static class CultNetSelectionProjections { /// The record without its payload. public const string Header = "header"; /// The full record. public const string Document = "document"; } /// One edge a hop traversed, carried on . [MessagePackObject] public sealed class CultNetEdge { /// The citing row. [Key("from")] public CultNetRecordRef From { get; set; } = new(); /// The declared reference alias the citer used. [Key("role")] public string Role { get; set; } = string.Empty; /// The cited row, its schema resolved to the leaf actually stored. [Key("to")] public CultNetRecordRef To { get; set; } = new(); /// "messagepack" when is present. [Key("payloadEncoding")] public string? PayloadEncoding { get; set; } /// The value attached to the edge (a dictionary reference's value); present under document projection only. [Key("payload")] public byte[]? Payload { get; set; } } /// /// A header record: minus its payload and payloadEncoding. /// [MessagePackObject] public sealed class CultNetRawDocumentHeader { [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; [Key("schemaName")] public string? SchemaName { get; set; } [Key("schemaVersion")] public string? SchemaVersion { get; set; } [Key("schemaContentHash")] public string? SchemaContentHash { get; set; } [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; [Key("storedAt")] public string StoredAt { get; set; } = string.Empty; [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } [Key("sourceAgentId")] public string? SourceAgentId { get; set; } [Key("sourceRole")] public string? SourceRole { get; set; } [Key("tags")] public string[]? Tags { get; set; } /// Builds a header by dropping a raw record's payload. public static CultNetRawDocumentHeader FromRecord(CultNetRawDocumentRecord record) => new() { SchemaId = record.SchemaId, SchemaName = record.SchemaName, SchemaVersion = record.SchemaVersion, SchemaContentHash = record.SchemaContentHash, RecordKey = record.RecordKey, StoredAt = record.StoredAt, SourceRuntimeId = record.SourceRuntimeId, SourceAgentId = record.SourceAgentId, SourceRole = record.SourceRole, Tags = record.Tags }; } /// /// The result of evaluating a selection, before it is carried by a wire message. Exactly one of /// / is populated, by projection. /// public sealed class CultNetSelectionPage { public uint Matched { get; set; } public ulong AsOf { get; set; } public string? Next { get; set; } public CultNetRawDocumentHeader[]? Headers { get; set; } public CultNetRawDocumentRecord[]? Documents { get; set; } public CultNetEdge[]? Edges { get; set; } } /// /// Refused typed at the door (section 2/3): a name, list, or comparison the declared shape cannot /// support. Carried as cultnet.error.v0 { code: "selection_invalid", details: { field, value } }. /// public sealed class CultNetSelectionInvalidException : Exception { public CultNetSelectionInvalidException(string field, string? value, string message) : base(message) { Field = field; Value = value; } /// The selection field that failed ("schemas", "keys", "fields[i].index", "cites.role", "cited.role", ...). public string Field { get; } /// The offending value, when there is one string worth naming. public string? Value { get; } } /// /// Validates a against the declared shape of the schemas it can /// reach - the door refusals of docs/cultnet-selection-cut.md section 2/3 (S1). /// public static class CultNetSelectionValidation { /// /// The declaration-independent half of the door: empty/blank schemas/keys and an /// unrecognised projection. Runs first inside , and is also the /// whole door for a caller with no descriptor list in hand - CultMesh's /// EnsureV0Compatible (docs/cultnet-selection-cut.md, R-F) runs this before its own v0 /// transport refusals, so Keys=[]/[""] is refused there too, not only in the /// reference runtime's full evaluator path. /// public static void ValidateShape(CultNetSelection selection) { if (selection == null) throw new ArgumentNullException(nameof(selection)); if (selection.Schemas != null && selection.Schemas.Length == 0) throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema."); if (selection.Keys != null && selection.Keys.Length == 0) throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key."); if (selection.Schemas != null) { foreach (var schema in selection.Schemas) { if (string.IsNullOrWhiteSpace(schema)) throw new CultNetSelectionInvalidException("schemas", schema, "selection.schemas carries an empty or whitespace entry."); } } if (selection.Keys != null) { foreach (var key in selection.Keys) { if (string.IsNullOrWhiteSpace(key)) throw new CultNetSelectionInvalidException("keys", key, "selection.keys carries an empty or whitespace entry."); } } if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document)) throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\"."); } public static void Validate(this CultNetSelection selection, IReadOnlyList allDescriptors) { if (allDescriptors == null) throw new ArgumentNullException(nameof(allDescriptors)); ValidateShape(selection); var reachable = ReachableSchemas(selection, allDescriptors); if (selection.Fields != null) { for (var i = 0; i < selection.Fields.Length; i++) { ValidateField(selection.Fields[i], i, reachable); } } if (selection.Cites != null) { if (string.IsNullOrEmpty(selection.Cites.Target?.SchemaId) || string.IsNullOrEmpty(selection.Cites.Target?.RecordKey)) throw new CultNetSelectionInvalidException("cites.target", null, "selection.cites.target requires schemaId and recordKey."); // R-E (docs/cultnet-selection-cut.md): a cites target names a schema through the same // alias matcher `schemas` uses. One that matches no declared schema is refused here, // never answered with an empty page by MatchesCitation silently treating an unresolved // target as "not found". if (!allDescriptors.Any(descriptor => CultNetSchemaAliasMatching.Matches(selection.Cites.Target!.SchemaId, descriptor))) throw new CultNetSelectionInvalidException("cites.target.schemaId", selection.Cites.Target!.SchemaId, $"selection.cites.target.schemaId \"{selection.Cites.Target!.SchemaId}\" does not match any declared schema."); if (selection.Cites.Role != null && !AnySchemaDeclaresRole(allDescriptors, selection.Cites.Role)) throw new CultNetSelectionInvalidException("cites.role", selection.Cites.Role, $"selection.cites.role \"{selection.Cites.Role}\" is not declared by any schema."); } if (selection.Cited != null) { if (string.IsNullOrEmpty(selection.Cited.Role)) throw new CultNetSelectionInvalidException("cited.role", null, "selection.cited.role must be non-empty."); if (!AnySchemaDeclaresRole(allDescriptors, selection.Cited.Role)) throw new CultNetSelectionInvalidException("cited.role", selection.Cited.Role, $"selection.cited.role \"{selection.Cited.Role}\" is not declared by any schema."); } } private static void ValidateField(CultNetFieldPredicate field, int index, IReadOnlyList reachable) { var prefix = $"fields[{index}]"; if (string.IsNullOrEmpty(field.Index)) throw new CultNetSelectionInvalidException($"{prefix}.index", null, $"{prefix}.index must be non-empty."); if (!CultNetSelectionOperators.TryParse(field.Op, out var op)) throw new CultNetSelectionInvalidException($"{prefix}.op", field.Op, $"{prefix}.op \"{field.Op}\" is not any_of/lt/le/ge/gt."); if (op == CultNetSelectionOperator.AnyOf) { if (field.Values == null || field.Values.Length == 0) throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix}.values must be non-empty for op any_of."); if (field.Number != null) throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix} carries both values and number; any_of takes only values."); } else { if (field.Number == null) throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix}.number is required for op {field.Op}."); if (!CultNetCanonicalNumber.IsCanonical(field.Number)) throw new CultNetSelectionInvalidException($"{prefix}.number", field.Number, $"{prefix}.number \"{field.Number}\" is not a canonical decimal string (Q-J): it must match {CultNetCanonicalNumber.Pattern} and not be \"-0\"."); if (field.Values != null) throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix} carries both values and number; a comparison takes only number."); } var declaring = reachable .Select(descriptor => descriptor.DeclaredMembers.FirstOrDefault(member => member.IndexAlias == field.Index)) .Where(member => member.MemberName != null) .ToArray(); if (declaring.Length == 0) throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is not declared by any schema this selection can reach."); if (op.IsComparison() && declaring.Any(member => !member.IsNumeric)) throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable."); } private static bool AnySchemaDeclaresRole(IReadOnlyList allDescriptors, string role) => allDescriptors.Any(descriptor => descriptor.DeclaredMembers.Any(member => member.IsReference && (member.IndexAlias ?? member.MemberName) == role)); /// The schemas reaches - every schema when absent. public static IReadOnlyList ReachableSchemas( CultNetSelection selection, IReadOnlyList allDescriptors) { if (selection.Schemas == null || selection.Schemas.Length == 0) return allDescriptors; return allDescriptors.Where(descriptor => CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)).ToArray(); } } }