Merge pull request 'cultnet: add unsupported_schema_version to the closed cultnet.error.v0 code set' (#26) from libby/error-code-unsupported-schema-version into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled

This commit is contained in:
Libby 2026-11-07 16:16:17 +00:00
commit ec7a009b02
18 changed files with 98 additions and 32 deletions

View file

@ -112,8 +112,9 @@ not guess. Adding a code is therefore a coordinated change across every runtime,
copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in
`packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns) and
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant).
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value).
## Live Invariants

View file

@ -28,7 +28,7 @@
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized"]
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,8 +36,8 @@
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write.", "type": ["string", "null"] },
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}

View file

@ -129,6 +129,22 @@
"current": null
}
}
},
{
"name": "unsupported_schema_version",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unsupported_schema_version: cultnet.hello.v1",
"routingHint": null,
"code": "unsupported_schema_version",
"details": {
"field": "schemaVersion",
"value": "cultnet.hello.v1",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -10,6 +10,7 @@ from typing import Any
import msgpack
from cultnet_py import (
unsupported_schema_version_error,
CultNetRawSnapshotResponse,
CultNetSimulationObservationHub,
CultNetRudpPacket,
@ -229,12 +230,7 @@ class CultMeshLocalServer:
if schema_version == "cultnet.simulation_observation.v0":
candidates = self._candidate_responses(message)
return candidates[0] if candidates else None
return self._error_response(
f"Unsupported CultNet message schema: {schema_version!r}.",
message_id=str(message.get("messageId") or ""),
code="unsupported_schema_version",
details={"schemaVersion": schema_version},
)
return unsupported_schema_version_error(schema_version).to_wire()
def _accept_loop(self) -> None:
while not self._stop.is_set():

View file

@ -5048,17 +5048,16 @@ class CultMeshTests(unittest.TestCase):
finally:
server.stop()
self.assertEqual(unsupported["messageId"], "bad-schema")
self.assertEqual(unsupported["code"], "unsupported_schema_version")
self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0")
self.assertIn("Unsupported CultNet message schema", unsupported["error"])
self.assertEqual(unsupported["details"]["field"], "schemaVersion")
self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
self.assertIn("unsupported_schema_version", unsupported["error"])
self.assertEqual(malformed_put["messageId"], "bad-put")
self.assertEqual(malformed_put["code"], "malformed_document_put")
self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
peer = CultMeshPeerCard(

View file

@ -49,6 +49,7 @@ from .messages import (
document_put_raw,
encode_witness_artifact_bundle_payload,
error_message,
unsupported_schema_version_error,
hello,
login,
login_success,
@ -261,6 +262,7 @@ __all__ = [
"encode_frame",
"encode_rudp_packet",
"error_message",
"unsupported_schema_version_error",
"hello",
"INTEROP_WIRE_CONTRACT",
"login",

View file

@ -38,6 +38,7 @@ from .cultmesh_contracts import (
simulation_fact_document,
)
from .messages import unsupported_schema_version_error as _unsupported_schema_version_error
from .schema_catalog import INTEROP_WIRE_CONTRACT, wire_message_schema_descriptors
from .client import create_rudp_schema_transport
from .transport import (
@ -407,14 +408,7 @@ def is_unsupported_cultnet_version(schema_version: Any) -> bool:
def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]:
schema_version = message.get("schemaVersion")
return {
"schemaVersion": "cultnet.error.v0",
"messageId": str(message.get("messageId") or ""),
"error": f"Unsupported CultNet message schema: {schema_version!r}.",
"code": "unsupported_schema_version",
"details": {"schemaVersion": schema_version},
}
return _unsupported_schema_version_error(message.get("schemaVersion")).to_wire()
def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]:

View file

@ -43,6 +43,7 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
)
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
@ -174,6 +175,16 @@ def error_message(
return CultNetMessage("cultnet.error.v0", body)
def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage:
"""The refusal for a message whose schemaVersion this peer does not speak (C# ForUnsupportedSchemaVersion)."""
value = None if schema_version is None else str(schema_version)
return error_message(
f"unsupported_schema_version: {value or ''}",
code="unsupported_schema_version",
details={"field": "schemaVersion", "value": value},
)
def login_success(
*,
nonce: str,

View file

@ -21,6 +21,7 @@ CULTNET_ERROR_CODES = (
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
)
WIRE_MESSAGE_SCHEMA_VERSIONS = (

View file

@ -2580,7 +2580,8 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0")
self.assertEqual(responses[0]["code"], "unsupported_schema_version")
self.assertEqual(responses[0]["messageId"], "v1-refused")
self.assertEqual(responses[0]["details"]["value"], schema_version)
self.assertNotIn("messageId", responses[0])
def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
from cultnet_py import CultNetSimulationConsensus

View file

@ -6,7 +6,7 @@ from pathlib import Path
import msgpack
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
@ -50,6 +50,7 @@ class CultNetErrorMessageTests(unittest.TestCase):
details={"field": "keys", "value": " "},
)
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"])
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
wire = error_message("boom").to_wire()
@ -57,7 +58,7 @@ class CultNetErrorMessageTests(unittest.TestCase):
def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None:
with self.assertRaises(ValueError):
error_message("x", code="unsupported_schema_version")
error_message("x", code="not_a_code")
bad = msgpack.packb(
{"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None},
use_bin_type=True,

View file

@ -28,7 +28,7 @@
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized"]
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,8 +36,8 @@
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write.", "type": ["string", "null"] },
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}

View file

@ -247,6 +247,7 @@ pub enum CultNetErrorCode {
VariantUnsupported,
UnownedSchema,
WriteNotAuthorized,
UnsupportedSchemaVersion,
}
impl CultNetErrorCode {
@ -259,6 +260,7 @@ impl CultNetErrorCode {
Self::VariantUnsupported => "variant_unsupported",
Self::UnownedSchema => "unowned_schema",
Self::WriteNotAuthorized => "write_not_authorized",
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
}
}
@ -271,6 +273,7 @@ impl CultNetErrorCode {
"variant_unsupported" => Ok(Self::VariantUnsupported),
"unowned_schema" => Ok(Self::UnownedSchema),
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
}
}

View file

@ -173,6 +173,21 @@ fn cases() -> Vec<Case> {
}),
},
},
Case {
name: "unsupported_schema_version",
// C#: ForUnsupportedSchemaVersion("cultnet.hello.v1")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "unsupported_schema_version: cultnet.hello.v1".to_string(),
code: Some(CultNetErrorCode::UnsupportedSchemaVersion),
details: Some(CultNetErrorDetails {
field: Some("schemaVersion".to_string()),
value: Some("cultnet.hello.v1".to_string()),
as_of: None,
current: None,
}),
},
},
]
}

View file

@ -194,6 +194,7 @@ export const CULTNET_ERROR_CODES = [
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
] as const;
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];

View file

@ -70,7 +70,7 @@ test("dropping code or details breaks the round trip and the decode", () => {
test("a code outside the closed enum is refused, as is a malformed details object", () => {
const base = decode(bytesOf(vectors.find((v) => v.name === "cursor_stale")!.messagePackHex)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...base, code: "unsupported_schema_version" }), /cultnet\.error\.v0/);
assert.throws(() => parseCultNetMessage({ ...base, code: "not_a_code" }), /cultnet\.error\.v0/);
assert.throws(() => parseCultNetMessage({ ...base, code: 7 }), /cultnet\.error\.v0/);
assert.throws(
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }),

View file

@ -511,7 +511,7 @@ namespace GameCult.Networking
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
/// <summary>
/// Gets or sets the machine-readable refusal code (R-N), e.g. <c>selection_invalid</c>,
/// <c>cursor_stale</c>, <c>cursor_invalid</c>, <c>reference_outside_target</c>, <c>unowned_schema</c>, <c>write_not_authorized</c>. Additive to
/// <c>cursor_stale</c>, <c>cursor_invalid</c>, <c>reference_outside_target</c>, <c>unowned_schema</c>, <c>write_not_authorized</c>, <c>unsupported_schema_version</c>. Additive to
/// <c>cultnet.error.v0</c>; a v0 peer that does not know this field ignores it.
/// </summary>
[Key("code")] public string? Code { get; set; }
@ -555,6 +555,18 @@ namespace GameCult.Networking
Details = new CultNetErrorDetails { Field = "recordKey", Value = request.RecordKey.Value }
};
/// <summary>
/// Builds the wire error for a message whose <c>schemaVersion</c> this peer does not speak: code
/// <c>unsupported_schema_version</c>, details <c>{ field: "schemaVersion", value }</c>.
/// </summary>
public static CultNetErrorMessage ForUnsupportedSchemaVersion(string? schemaVersion) =>
new CultNetErrorMessage
{
Error = $"unsupported_schema_version: {schemaVersion}",
Code = "unsupported_schema_version",
Details = new CultNetErrorDetails { Field = "schemaVersion", Value = schemaVersion }
};
/// <summary>
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.

View file

@ -220,6 +220,19 @@ namespace GameCult.Networking.Tests
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0"));
}
[Test]
public void ErrorMessage_UnsupportedSchemaVersionRealBytes_MatchesItsSchema()
{
var message = CultNetErrorMessage.ForUnsupportedSchemaVersion("cultnet.hello.v1");
Assert.That(message.Code, Is.EqualTo("unsupported_schema_version"));
Assert.That(message.Details!.Field, Is.EqualTo("schemaVersion"));
Assert.That(message.Details.Value, Is.EqualTo("cultnet.hello.v1"));
AssertErrorMatchesSchema(message);
// The bytes cultnet-rs's error_message.rs unsupported_schema_version case decodes and re-encodes.
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0"));
}
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
{
var validator = new MiniJsonSchemaValidator(SchemaDir());