Land fix batch 3's R-S: the schemas describe the bytes C# writes

New contracts/cultnet/cultnet.raw-document-header.schema.json for
CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all,
so headers must not $ref the record schema (which required both). The
response page's own schema now lists every property CultNetSnapshotResponseRawV1Message
declares as `required` (present on the wire, nil included) rather than only
the four that happened to be non-nullable - MessagePack-CSharp's map-mode
formatter writes every declared key regardless of null, so `headers`/`documents`
are always both present, one an array and the other an explicit null by
projection. The old `oneOf: [{required:[headers]},{required:[documents]}]`
matched both branches on every real page (both keys present) and so never
validated anything; it is now `oneOf` over each key's type (array vs null),
which is mutually exclusive. cultnet.raw-document-record.schema.json gained
schemaName/schemaVersion/schemaContentHash (present on every real record, not
previously in the schema at all - any real page would have failed
additionalProperties:false on them). fieldPredicate's two oneOf branches each
gained `not: {required: [the other]}`, so `values` and `number` cannot both
be present (previously only `required` was checked, which does not exclude
the sibling key).

Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset
validator (type/required/additionalProperties/properties/items/const/enum/
pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) -
no schema-validation dependency existed in this repo, and this only needs to
cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests
decodes real wire bytes from a live CultNetDatabaseServer (header projection,
document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson
and checks them against the committed schema files, plus three
fieldPredicate-shape tests pinning the values/number exclusivity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 20:12:58 +02:00
commit f47060d5c2
6 changed files with 480 additions and 21 deletions

View file

@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
"title": "CultNet Raw Document Header",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -2,17 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"payloadEncoding",
"payload"
"payload",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"payloadEncoding": { "const": "messagepack" },
@ -21,11 +32,11 @@
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
},
"sourceRuntimeId": { "type": "string", "minLength": 1 },
"sourceAgentId": { "type": "string", "minLength": 1 },
"sourceRole": { "type": "string", "minLength": 1 },
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": "array",
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}

View file

@ -66,8 +66,16 @@
}
},
"oneOf": [
{ "required": ["values"], "properties": { "op": { "const": "any_of" } } },
{ "required": ["number"], "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } }
{
"required": ["values"],
"not": { "required": ["number"] },
"properties": { "op": { "const": "any_of" } }
},
{
"required": ["number"],
"not": { "required": ["values"] },
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
}
]
},
"citation": {

View file

@ -2,44 +2,58 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
"title": "CultNet Snapshot Response Raw Message v1",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
"type": "object",
"required": ["schemaVersion", "messageId", "matched", "asOf"],
"required": [
"schemaVersion", "messageId", "matched", "asOf", "next",
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
],
"additionalProperties": false,
"oneOf": [
{ "required": ["headers"] },
{ "required": ["documents"] }
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
},
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
}
],
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
"messageId": { "type": "string", "minLength": 1 },
"matched": { "type": "integer", "minimum": 0 },
"asOf": { "type": "integer", "minimum": 0 },
"next": { "type": "string" },
"next": { "type": ["string", "null"] },
"headers": {
"type": "array",
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
},
"documents": {
"type": "array",
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
},
"edges": {
"type": "array",
"type": ["array", "null"],
"items": {
"type": "object",
"required": ["from", "role", "to"],
"required": ["from", "role", "to", "payloadEncoding", "payload"],
"additionalProperties": false,
"properties": {
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 },
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"payloadEncoding": { "type": "string" },
"payload": { "type": "string" }
"payloadEncoding": { "type": ["string", "null"] },
"payload": {
"type": ["string", "null"],
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
}
}
}
},
"shardId": { "type": "string", "minLength": 1 },
"shardEpoch": { "type": "integer" },
"shardLogSequence": { "type": "integer" }
"shardId": { "type": ["string", "null"], "minLength": 1 },
"shardEpoch": { "type": ["integer", "null"] },
"shardLogSequence": { "type": ["integer", "null"] }
}
}

View file

@ -0,0 +1,165 @@
#nullable enable
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// R-S (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3", F12): the committed
// contracts/cultnet/*.schema.json files describe the bytes MessagePack-CSharp actually writes -
// headers get their own schema (no payload/payloadEncoding, which CultNetRawDocumentHeader has
// no members for at all), the snapshot page's shape matches map-mode output (every declared key
// present, nil included), and fieldPredicate refuses values/number together. This decodes real
// wire bytes from a live CultNetDatabaseServer and checks them against the committed schema
// files with MiniJsonSchemaValidator, rather than asserting on the C# objects alone - a schema
// file can drift from the bytes even while the C# types stay internally consistent, which is
// exactly what F12 was.
public sealed class CultNetSchemaContractTests
{
private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development();
private static string SchemaDir() => Path.Combine(RepoRoot(), "contracts", "cultnet");
private static string RepoRoot()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
}
private static JsonElement ToJson<T>(T message) where T : ICultNetSchemaMessage
{
var bytes = CultNetSchemaMessageSerialization.Serialize(message);
var json = MessagePackSerializer.ConvertToJson(bytes);
return JsonDocument.Parse(json).RootElement;
}
[Test]
public async Task SnapshotResponseRawV1_HeaderProjectionRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "header-projection",
Selection = new CultNetSelection() // default projection is "header"
});
Assert.That(response.Headers, Is.Not.Null);
Assert.That(response.Documents, Is.Null);
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
[Test]
public async Task SnapshotResponseRawV1_DocumentProjectionRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
await database.PutAsync(new CultRecordKey("v1:weapon"), new CultNetSelectionEvaluatorTests.SelLeafA
{
Name = "sword",
Kind = "weapon",
Mass = 3
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "document-projection",
Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }
});
Assert.That(response.Documents, Is.Not.Null);
Assert.That(response.Headers, Is.Null);
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
[Test]
public async Task SnapshotResponseRawV1_WithEdgesRealBytes_MatchesItsSchema()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache);
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
await database.PutAsync(new CultRecordKey("target"), target);
await database.PutAsync(new CultRecordKey("citer"), new CultNetSelectionEvaluatorTests.SelCiter
{
Name = "citer",
Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target"))
});
using var server = new Server(cache, DevelopmentServerSecurity);
using var databaseServer = new CultNetDatabaseServer(server, database);
var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
{
MessageId = "with-edges",
Selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "Design", Exists = true },
Projection = CultNetSelectionProjections.Document
}
});
Assert.That(response.Edges, Is.Not.Null.And.Length.GreaterThan(0));
var validator = new MiniJsonSchemaValidator(SchemaDir());
validator.AssertValid(ToJson(response), "cultnet.snapshot-response-raw.v1.schema.json");
}
// R-S: fieldPredicate is now exclusive - values and number may not both be present, even
// though each alone satisfies one oneOf branch's `required`. Hand-built JSON, not a C# object,
// because CultNetFieldPredicate itself has no door stopping this shape from being constructed
// in memory - the door is CultNetSelectionValidation, a different layer; this test is about the
// wire schema's own oneOf, independent of that door.
[Test]
public void FieldPredicate_ValuesAndNumberTogether_FailsItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"mass","op":"any_of","values":["1"],"number":"1"}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Not.Empty);
}
[Test]
public void FieldPredicate_ValuesOnly_MatchesItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"kind","op":"any_of","values":["weapon"]}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Empty, string.Join("; ", errors));
}
[Test]
public void FieldPredicate_NumberOnly_MatchesItsSchema()
{
var instance = JsonDocument.Parse(
"""{"index":"mass","op":"gt","number":"1"}""").RootElement;
var validator = new MiniJsonSchemaValidator(SchemaDir());
var errors = validator.Validate(instance, "cultnet.selection.schema.json#/$defs/fieldPredicate");
Assert.That(errors, Is.Empty, string.Join("; ", errors));
}
}
}

View file

@ -0,0 +1,226 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameCult.Networking.Tests
{
/// <summary>
/// A deliberately small JSON Schema (draft 2020-12) validator covering only the constructs
/// contracts/cultnet/*.schema.json actually use: object/array/string/integer/boolean/null types
/// (plain or as a ["type","null"] union), required, additionalProperties: false, properties,
/// items, const, enum, pattern, minLength, minItems, oneOf, and $ref - same-document
/// ("#/$defs/name"), cross-document ("other.schema.json"), and both ("other.schema.json#/$defs/name").
/// Not a general validator: it exists for R-S (docs/cultnet-selection-cut.md, fix batch 3) to
/// decode real C# wire bytes and check them against the committed schema files, which no
/// dependency in this repo already does - see CultNetSchemaContractTests.
/// </summary>
internal sealed class MiniJsonSchemaValidator
{
private readonly string _schemaDir;
private readonly Dictionary<string, JsonElement> _docCache = new(StringComparer.Ordinal);
public MiniJsonSchemaValidator(string schemaDir)
{
_schemaDir = schemaDir;
}
/// <summary>Validates <paramref name="instance"/> against the named schema file's root schema. Throws with every collected error when invalid.</summary>
public void AssertValid(JsonElement instance, string schemaFileName)
{
var errors = Validate(instance, schemaFileName);
if (errors.Count > 0)
{
throw new InvalidOperationException(
$"{instance} does not satisfy {schemaFileName}:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}");
}
}
/// <summary>Validates against a $defs entry of the named schema file, e.g. "cultnet.selection.schema.json#/$defs/fieldPredicate".</summary>
public List<string> Validate(JsonElement instance, string refText)
{
var errors = new List<string>();
var (schema, file) = Resolve(refText, currentFile: "");
ValidateAgainst(instance, schema, file, "$", errors);
return errors;
}
private JsonElement LoadDoc(string fileName)
{
if (_docCache.TryGetValue(fileName, out var cached)) return cached;
var path = Path.Combine(_schemaDir, fileName);
var doc = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
_docCache[fileName] = doc;
return doc;
}
private (JsonElement Schema, string File) Resolve(string refText, string currentFile)
{
var hash = refText.IndexOf('#');
string file;
string pointer;
if (hash < 0)
{
file = refText;
pointer = "";
}
else
{
var filePart = refText[..hash];
file = filePart.Length == 0 ? currentFile : filePart;
pointer = refText[(hash + 1)..];
}
var doc = LoadDoc(file);
if (string.IsNullOrEmpty(pointer) || pointer == "/") return (doc, file);
var current = doc;
foreach (var segment in pointer.TrimStart('/').Split('/'))
{
var key = segment.Replace("~1", "/").Replace("~0", "~");
current = current.GetProperty(key);
}
return (current, file);
}
private void ValidateAgainst(JsonElement instance, JsonElement schema, string file, string path, List<string> errors)
{
if (schema.TryGetProperty("$ref", out var refProp))
{
var (resolved, resolvedFile) = Resolve(refProp.GetString()!, file);
ValidateAgainst(instance, resolved, resolvedFile, path, errors);
return;
}
if (schema.TryGetProperty("oneOf", out var oneOf))
{
var matches = 0;
var branchErrors = new List<string>();
foreach (var branch in oneOf.EnumerateArray())
{
var local = new List<string>();
ValidateAgainst(instance, branch, file, path, local);
if (local.Count == 0) matches++;
else branchErrors.AddRange(local);
}
if (matches != 1)
{
errors.Add($"{path}: oneOf matched {matches} branches (want exactly 1); branch errors: {string.Join(" | ", branchErrors)}");
}
}
if (schema.TryGetProperty("const", out var constEl))
{
if (!JsonElementDeepEquals(instance, constEl))
errors.Add($"{path}: expected const {constEl}, got {instance}");
}
if (schema.TryGetProperty("enum", out var enumEl))
{
if (!enumEl.EnumerateArray().Any(candidate => JsonElementDeepEquals(instance, candidate)))
errors.Add($"{path}: value {instance} is not one of the declared enum values");
}
if (schema.TryGetProperty("type", out var typeEl))
{
var allowed = typeEl.ValueKind == JsonValueKind.Array
? typeEl.EnumerateArray().Select(t => t.GetString()!).ToArray()
: new[] { typeEl.GetString()! };
if (!allowed.Any(t => MatchesType(instance, t)))
errors.Add($"{path}: kind {instance.ValueKind} does not satisfy type [{string.Join(",", allowed)}]");
}
// Null short-circuits the value-shaped checks below (a nilled key still satisfies a
// ["...","null"] type, but has no length/properties/items of its own to check).
if (instance.ValueKind == JsonValueKind.Null)
{
return;
}
if (instance.ValueKind == JsonValueKind.String)
{
var value = instance.GetString()!;
if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32())
errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}");
if (schema.TryGetProperty("pattern", out var pattern) && !Regex.IsMatch(value, pattern.GetString()!))
errors.Add($"{path}: \"{value}\" does not match pattern {pattern.GetString()}");
}
if (instance.ValueKind == JsonValueKind.Array)
{
var items = instance.EnumerateArray().ToArray();
if (schema.TryGetProperty("minItems", out var minItems) && items.Length < minItems.GetInt32())
errors.Add($"{path}: array shorter than minItems {minItems.GetInt32()}");
if (schema.TryGetProperty("items", out var itemSchema))
{
for (var i = 0; i < items.Length; i++)
ValidateAgainst(items[i], itemSchema, file, $"{path}[{i}]", errors);
}
}
if (instance.ValueKind == JsonValueKind.Object)
{
var propertyNames = instance.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
if (schema.TryGetProperty("required", out var required))
{
foreach (var name in required.EnumerateArray().Select(r => r.GetString()!))
{
if (!propertyNames.Contains(name))
errors.Add($"{path}: missing required property \"{name}\"");
}
}
var declared = schema.TryGetProperty("properties", out var properties)
? properties.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal)
: new HashSet<string>(StringComparer.Ordinal);
if (schema.TryGetProperty("additionalProperties", out var additional) &&
additional.ValueKind == JsonValueKind.False)
{
foreach (var name in propertyNames)
{
if (!declared.Contains(name))
errors.Add($"{path}: property \"{name}\" is not declared and additionalProperties is false");
}
}
if (properties.ValueKind == JsonValueKind.Object)
{
foreach (var propertySchema in properties.EnumerateObject())
{
if (instance.TryGetProperty(propertySchema.Name, out var propertyValue))
ValidateAgainst(propertyValue, propertySchema.Value, file, $"{path}.{propertySchema.Name}", errors);
}
}
}
}
private static bool MatchesType(JsonElement instance, string type) => type switch
{
"object" => instance.ValueKind == JsonValueKind.Object,
"array" => instance.ValueKind == JsonValueKind.Array,
"string" => instance.ValueKind == JsonValueKind.String,
"boolean" => instance.ValueKind is JsonValueKind.True or JsonValueKind.False,
"null" => instance.ValueKind == JsonValueKind.Null,
"integer" => instance.ValueKind == JsonValueKind.Number && instance.TryGetInt64(out _),
"number" => instance.ValueKind == JsonValueKind.Number,
_ => throw new NotSupportedException($"Unsupported schema type \"{type}\".")
};
private static bool JsonElementDeepEquals(JsonElement a, JsonElement b)
{
if (a.ValueKind != b.ValueKind) return false;
return a.ValueKind switch
{
JsonValueKind.String => a.GetString() == b.GetString(),
JsonValueKind.Number => a.GetRawText() == b.GetRawText(),
JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true,
_ => a.GetRawText() == b.GetRawText()
};
}
}
}