cultnet error message parity: TS type + Python builder/decoder, shared C#/Rust vectors #14

Merged
libby merged 3 commits from libby/cultnet-error-parity into main 2026-11-07 12:11:39 +00:00
8 changed files with 442 additions and 0 deletions

View file

@ -0,0 +1,134 @@
{
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
"vectors": [
{
"name": "selection_invalid_schemas_null",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: must not be empty",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "schemas",
"value": null,
"asOf": null,
"current": null
}
}
},
{
"name": "selection_invalid_field_value",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: blank key entry",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "keys",
"value": " ",
"asOf": null,
"current": null
}
}
},
{
"name": "cursor_stale",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: cursor is stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 1,
"current": 2
}
}
},
{
"name": "cursor_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: cannot parse cursor",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "reference_outside_target",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "reference_outside_target: outside declared target",
"routingHint": null,
"code": "reference_outside_target",
"details": null
}
},
{
"name": "variant_unsupported_subscription_ended",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": {
"field": "subscriptionId",
"value": "sub-1",
"asOf": null,
"current": null
}
}
},
{
"name": "variant_unsupported_no_subscription",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": null
}
},
{
"name": "unowned_schema",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
"routingHint": null,
"code": "unowned_schema",
"details": {
"field": "schemaId",
"value": "some.schema.v1",
"asOf": null,
"current": null
}
}
},
{
"name": "write_not_authorized",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized: not granted",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "some:key",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -38,6 +38,7 @@ from .cultmesh_contracts import (
)
from .framing import decode_frame, encode_frame, read_frame, write_frame
from .messages import (
CULTNET_ERROR_CODES,
CultNetMessage,
CultNetWitnessArtifactBundle,
compute_simulation_claim_hash,
@ -47,6 +48,7 @@ from .messages import (
document_delete,
document_put_raw,
encode_witness_artifact_bundle_payload,
error_message,
hello,
login,
login_success,
@ -165,6 +167,7 @@ __all__ = [
"simulation_fact_document",
"verse_catalog_request",
"verse_from_wire",
"CULTNET_ERROR_CODES",
"CultNetMessage",
"CultNetWitnessArtifactBundle",
"CultNetDatabaseSubscription",
@ -247,6 +250,7 @@ __all__ = [
"encode_witness_artifact_bundle_payload",
"encode_frame",
"encode_rudp_packet",
"error_message",
"hello",
"INTEROP_WIRE_CONTRACT",
"login",

View file

@ -28,9 +28,61 @@ def parse_message(payload: bytes) -> CultNetMessage:
raise ValueError("CultNet schema-v0 messages must declare schemaVersion")
body = dict(decoded)
del body["schemaVersion"]
if schema_version == "cultnet.error.v0":
_validate_error_body(body)
return CultNetMessage(schema_version=schema_version, body=body)
# Closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). Adding a
# code is one coordinated change across every runtime; a peer refuses a code it does not know.
CULTNET_ERROR_CODES: tuple[str, ...] = (
"selection_invalid",
"cursor_stale",
"cursor_invalid",
"reference_outside_target",
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
)
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
def _is_uint(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _validate_error_body(body: dict[str, Any]) -> None:
"""Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
expected = ("error", "routingHint", "code", "details")
if set(body) != set(expected):
raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}")
if not isinstance(body["error"], str) or not body["error"]:
raise ValueError("cultnet.error.v0 error must be a non-empty string")
code = body["code"]
if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code")
hint = body["routingHint"]
if hint is not None:
if (
not isinstance(hint, dict)
or set(hint) != {"shard", "reason"}
or not isinstance(hint["reason"], str)
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
):
raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
details = body["details"]
if details is not None:
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
for key in ("field", "value"):
if details[key] is not None and not isinstance(details[key], str):
raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
for key in ("asOf", "current"):
if details[key] is not None and not _is_uint(details[key]):
raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
def hello(
*,
runtime_id: str,
@ -99,6 +151,29 @@ def verify_session(
)
def error_message(
error: str,
*,
code: str | None = None,
details: dict[str, Any] | None = None,
routing_hint: dict[str, Any] | None = None,
) -> CultNetMessage:
"""Build cultnet.error.v0 with every key written (nil included), as the C# map formatter does.
``details`` may name any of field/value/asOf/current; the rest are written as null.
"""
body = {
"error": error,
"routingHint": routing_hint,
"code": code,
"details": None if details is None else {key: details.get(key) for key in _ERROR_DETAILS_KEYS},
}
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
_validate_error_body(body)
return CultNetMessage("cultnet.error.v0", body)
def login_success(
*,
nonce: str,

View file

@ -0,0 +1,90 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
import msgpack
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message
# 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).
_REPO = Path(__file__).resolve().parents[3]
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
_SCHEMA = json.loads((_REPO / "contracts/cultnet/cultnet.error.schema.json").read_text(encoding="utf-8"))
class CultNetErrorMessageTests(unittest.TestCase):
def test_closed_code_set_equals_the_schema_enum(self) -> None:
schema_codes = [c for c in _SCHEMA["properties"]["code"]["enum"] if c is not None]
self.assertEqual(sorted(CULTNET_ERROR_CODES), sorted(schema_codes))
def test_vectors_cover_every_code(self) -> None:
self.assertEqual({v["message"]["code"] for v in _VECTORS}, set(CULTNET_ERROR_CODES))
def test_decodes_csharp_and_rust_written_bytes_with_every_key(self) -> None:
for vector in _VECTORS:
message = parse_message(bytes.fromhex(vector["messagePackHex"]))
self.assertEqual(message.to_wire(), vector["message"], vector["name"])
self.assertEqual(
list(message.to_wire()), ["schemaVersion", "error", "routingHint", "code", "details"], vector["name"]
)
def test_reencode_is_byte_identical(self) -> None:
for vector in _VECTORS:
expected = bytes.fromhex(vector["messagePackHex"])
self.assertEqual(parse_message(expected).to_bytes(), expected, vector["name"])
def test_builder_produces_the_csharp_bytes(self) -> None:
by_name = {v["name"]: bytes.fromhex(v["messagePackHex"]) for v in _VECTORS}
stale = error_message(
"cursor_stale: cursor is stale", code="cursor_stale", details={"asOf": 1, "current": 2}
)
self.assertEqual(stale.to_bytes(), by_name["cursor_stale"])
invalid = error_message("cursor_invalid: cannot parse cursor", code="cursor_invalid")
self.assertEqual(invalid.to_bytes(), by_name["cursor_invalid"])
selection = error_message(
"selection_invalid: blank key entry",
code="selection_invalid",
details={"field": "keys", "value": " "},
)
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
wire = error_message("boom").to_wire()
self.assertEqual(wire, {"schemaVersion": "cultnet.error.v0", "error": "boom", "routingHint": None, "code": None, "details": None})
def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None:
with self.assertRaises(ValueError):
error_message("x", code="unsupported_schema_version")
bad = msgpack.packb(
{"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None},
use_bin_type=True,
)
with self.assertRaises(ValueError):
parse_message(bad)
def test_dropping_code_or_details_is_refused(self) -> None:
vector = next(v for v in _VECTORS if v["name"] == "cursor_stale")
for dropped in ("code", "details", "routingHint"):
wire = dict(vector["message"])
del wire[dropped]
with self.assertRaises(ValueError, msg=dropped):
parse_message(msgpack.packb(wire, use_bin_type=True))
def test_malformed_details_are_refused(self) -> None:
base = dict(next(v for v in _VECTORS if v["name"] == "cursor_stale")["message"])
for details in (
{"field": None, "value": None, "asOf": 1},
{"field": None, "value": None, "asOf": -1, "current": 2},
{"field": None, "value": None, "asOf": 1, "current": 2, "extra": 1},
):
with self.assertRaises(ValueError, msg=str(details)):
parse_message(msgpack.packb({**base, "details": details}, use_bin_type=True))
with self.assertRaises(ValueError):
error_message("x", code="cursor_stale", details={"asOf": 1, "bogus": 2})
if __name__ == "__main__":
unittest.main()

View file

@ -211,3 +211,22 @@ fn round_trip_is_stable() {
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
}
}
/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes
/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be
/// pinned to bytes this crate no longer pins.
#[test]
fn error_vectors_file_matches_the_inline_csharp_captures() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../contracts/cultnet/interop/error-vectors.json");
let file: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json"))
.expect("parse error-vectors.json");
let vectors = file["vectors"].as_array().expect("vectors array");
let all = cases();
assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases");
for (vector, case) in vectors.iter().zip(all.iter()) {
assert_eq!(vector["name"].as_str(), Some(case.name));
assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name);
}
}

View file

@ -165,9 +165,38 @@ export interface CultNetLoginSuccessMessage {
session: string;
}
/** The closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). */
export const CULTNET_ERROR_CODES = [
"selection_invalid",
"cursor_stale",
"cursor_invalid",
"reference_outside_target",
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
] as const;
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
export interface CultNetErrorRoutingHint {
shard: Record<string, unknown> | null;
reason: string;
}
export interface CultNetErrorDetails {
field: string | null;
value: string | null;
asOf: number | null;
current: number | null;
}
/** Every key is written on the wire, null included (the C# map formatter never omits a key). */
export interface CultNetErrorMessage {
schemaVersion: "cultnet.error.v0";
error: string;
routingHint: CultNetErrorRoutingHint | null;
code: CultNetErrorCode | null;
details: CultNetErrorDetails | null;
}
export interface CultNetSampleChangeNameMessage {
@ -842,6 +871,10 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
return {
schemaVersion: "cultnet.error.v0",
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
routingHint: null,
code: null,
details: null,
};
case 5:
return {

View file

@ -13,7 +13,11 @@ export {
type CultNetDatabaseChangeRawMessage,
type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES,
type CultNetErrorCode,
type CultNetErrorDetails,
type CultNetErrorMessage,
type CultNetErrorRoutingHint,
type CultNetHelloMessage,
type CultNetLoginMessage,
type CultNetLoginSuccessMessage,

View file

@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import {
CULTNET_ERROR_CODES,
cultNetSchemas,
encodeCultNetMessageForWire,
parseCultNetMessage,
type CultNetErrorMessage,
} from "../src";
// 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).
const VECTORS = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "error-vectors.json");
const vectors = (JSON.parse(readFileSync(VECTORS, "utf8")) as {
vectors: { name: string; messagePackHex: string; message: unknown }[];
}).vectors;
const bytesOf = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex, "hex"));
const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex").toUpperCase();
const wireOf = (message: unknown): Uint8Array => encode(encodeCultNetMessageForWire(message as CultNetErrorMessage));
test("the error vector file carries a case for every closed code", () => {
const codes = new Set(vectors.map((v) => (v.message as CultNetErrorMessage).code));
for (const code of CULTNET_ERROR_CODES) {
assert.ok(codes.has(code), `no vector for ${code}`);
}
});
test("a null code with null routingHint and details is a valid error message (no captured bytes carry one)", () => {
const message = parseCultNetMessage({
schemaVersion: "cultnet.error.v0", error: "boom", routingHint: null, code: null, details: null,
});
assert.equal((message as CultNetErrorMessage).code, null);
});
test("the closed code union equals the schema's enum", () => {
const schemaCodes = ((cultNetSchemas.errorSchema.properties.code as { enum?: (string | null)[] }).enum ?? []).filter((c) => c !== null);
assert.deepEqual([...CULTNET_ERROR_CODES].sort(), [...schemaCodes].sort());
});
test("C#/Rust-written error bytes decode into the typed message with every key", () => {
for (const vector of vectors) {
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
assert.deepEqual(parsed, vector.message, vector.name);
assert.deepEqual(Object.keys(parsed), ["schemaVersion", "error", "routingHint", "code", "details"], vector.name);
}
});
test("a parsed error message re-encodes to the same bytes", () => {
for (const vector of vectors) {
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
assert.equal(hexOf(wireOf(parsed)), vector.messagePackHex, vector.name);
}
});
test("dropping code or details breaks the round trip and the decode", () => {
const vector = vectors.find((v) => v.name === "cursor_stale")!;
for (const dropped of ["code", "details", "routingHint"]) {
const copy = { ...(decode(bytesOf(vector.messagePackHex)) as Record<string, unknown>) };
delete copy[dropped];
assert.throws(() => parseCultNetMessage(copy), /cultnet\.error\.v0/, `missing ${dropped} must be refused`);
assert.notEqual(hexOf(encode(copy)), vector.messagePackHex);
}
});
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: 7 }), /cultnet\.error\.v0/);
assert.throws(
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }),
/cultnet\.error\.v0/,
);
assert.throws(
() => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: -1, current: 2 } }),
/cultnet\.error\.v0/,
);
});