database_change_raw schema accepts the nils the C# writer sets (document, recordKey, schemaId); cultnet-ts validates the C# commit vector against it; C# and Python pin it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-11 08:31:44 +00:00
commit 3200067795
6 changed files with 103 additions and 13 deletions

View file

@ -146,6 +146,9 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe
commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state
between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only
within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next.
- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document`
nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change
still needs a document, and a removed one a `recordKey` and `schemaId`.
- Client-owned input documents may be predicted locally only inside explicit
`CultNetClientAuthorityScope` declarations.
- Predicted input state is not authoritative shared state. The shard log still

View file

@ -10,17 +10,29 @@
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"changeKind": { "enum": ["added", "updated", "removed"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" },
"recordKey": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 },
"document": {
"oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }]
},
"recordKey": { "type": ["string", "null"], "minLength": 1 },
"schemaId": { "type": ["string", "null"], "minLength": 1 },
"commitId": { "type": ["string", "null"], "minLength": 1 },
"commitCount": { "type": ["integer", "null"], "minimum": 2 }
},
"allOf": [
"oneOf": [
{
"if": { "properties": { "changeKind": { "const": "removed" } } },
"then": { "required": ["recordKey", "schemaId"] },
"else": { "required": ["document"] }
"properties": {
"changeKind": { "const": "removed" },
"recordKey": { "type": "string" },
"schemaId": { "type": "string" }
},
"required": ["recordKey", "schemaId"]
},
{
"properties": {
"changeKind": { "enum": ["added", "updated"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" }
},
"required": ["document"]
}
]
}

View file

@ -429,9 +429,9 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]:
"messageId": common["messageId"],
"subscriptionId": common["subscriptionId"],
"changeKind": {"enum": ["added", "updated", "removed"]},
"document": {"$ref": "#/$defs/rawDocumentRecord"},
"schemaId": common["schemaId"],
"recordKey": common["recordKey"],
"document": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/rawDocumentRecord"}]},
"schemaId": {"type": ["string", "null"], "minLength": 1},
"recordKey": {"type": ["string", "null"], "minLength": 1},
"commitId": {"type": ["string", "null"], "minLength": 1},
"commitCount": {"type": ["integer", "null"], "minimum": 2},
},

View file

@ -2315,9 +2315,10 @@ class CultNetTests(unittest.TestCase):
change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"])
self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"])
self.assertIn("document", change_schema["properties"])
self.assertIn("schemaId", change_schema["properties"])
self.assertIn("recordKey", change_schema["properties"])
# The C# writer sets every key, nil where unset (a removed change's document, an added change's recordKey and schemaId).
self.assertEqual(change_schema["properties"]["document"]["oneOf"][0], {"type": "null"})
self.assertEqual(change_schema["properties"]["schemaId"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["recordKey"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2})

View file

@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
import { decodeMulti } from "@msgpack/msgpack";
import { cultNetSchemas, parseCultNetMessage } from "../src/index";
// tests/vectors/database-change-commit/two-change-commit.msgpack is written only by the C# reference: two removed
// database_change_raw messages of one commit, every key present, nil where unset (document included).
const vector = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "database-change-commit", "two-change-commit.msgpack");
const changes = () => [...decodeMulti(readFileSync(vector))] as Record<string, unknown>[];
test("a two-change commit written by C# decodes with its commitId, commitCount and changes", () => {
const messages = changes().map((message) => parseCultNetMessage(structuredClone(message)));
assert.equal(messages.length, 2);
for (const message of messages) {
assert.equal(message.schemaVersion, "cultnet.database_change_raw.v0");
assert.equal((message as { commitId?: unknown }).commitId, "9f1c0f0e4b7a4c1f9d2e");
assert.equal((message as { commitCount?: unknown }).commitCount, 2);
assert.equal((message as { changeKind?: unknown }).changeKind, "removed");
assert.equal((message as { subscriptionId?: unknown }).subscriptionId, "sub-1");
assert.equal((message as { schemaId?: unknown }).schemaId, "schema-note");
}
assert.deepEqual(messages.map((message) => (message as { recordKey?: unknown }).recordKey), ["note:a", "note:b"]);
});
test("the database_change_raw schema accepts the C# vector's removed changes (document: nil) and still refuses an added change without a document", () => {
const ajv = new Ajv2020({ allErrors: true, strict: false });
ajv.addSchema(cultNetSchemas.rawDocumentRecordSchema);
const validate = ajv.compile(cultNetSchemas.databaseChangeRawSchema);
for (const change of changes()) {
assert.equal(change.document, null, "the C# writer wrote the key, nil");
assert.equal(validate(change), true, JSON.stringify(validate.errors));
assert.equal(validate({ ...change, changeKind: "added" }), false, "an added change needs a document");
}
});

View file

@ -1,5 +1,7 @@
#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
@ -246,6 +248,39 @@ namespace GameCult.Networking.Tests
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0"));
}
// The C# writer sets every key (nil where unset), so a removed change carries document: nil, and an added change
// carries recordKey/schemaId: nil. The schema describes those bytes; it still refuses an added change with no document.
private static List<JsonElement> CommitVectorChanges()
{
var path = Path.Combine(RepoRoot(), "tests", "vectors", "database-change-commit", "two-change-commit.msgpack");
var reader = new MessagePackReader(File.ReadAllBytes(path));
var changes = new List<JsonElement>();
while (!reader.End)
changes.Add(JsonDocument.Parse(MessagePackSerializer.ConvertToJson(reader.ReadRaw().ToArray())).RootElement);
return changes;
}
[Test]
public void DatabaseChangeRaw_TheCommitVectorsRemovedChangesWithANilDocument_MatchTheSchema()
{
var validator = new MiniJsonSchemaValidator(SchemaDir());
var changes = CommitVectorChanges();
Assert.That(changes, Has.Count.EqualTo(2));
foreach (var change in changes)
{
Assert.That(change.GetProperty("document").ValueKind, Is.EqualTo(JsonValueKind.Null), "the C# writer wrote the key, nil");
validator.AssertValid(change, "cultnet.database-change-raw.schema.json");
}
}
[Test]
public void DatabaseChangeRaw_AnAddedChangeWithANilDocument_IsRefused()
{
var validator = new MiniJsonSchemaValidator(SchemaDir());
var removed = CommitVectorChanges()[0].GetRawText().Replace("\"removed\"", "\"added\"");
Assert.Throws<InvalidOperationException>(() => validator.AssertValid(JsonDocument.Parse(removed).RootElement, "cultnet.database-change-raw.schema.json"));
}
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
{
var validator = new MiniJsonSchemaValidator(SchemaDir());