cultnet error contract: one message_invalid code replaces malformed_document_put/delete #41

Merged
libby merged 2 commits from libby/error-message-invalid into main 2026-11-08 09:17:52 +00:00
26 changed files with 153 additions and 57 deletions

View file

@ -114,7 +114,9 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and
`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),
`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).
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
## 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", "unsupported_schema_version"]
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
},
"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,7 +36,7 @@
"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\". unsupported_schema_version: \"schemaVersion\".", "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\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "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

@ -145,6 +145,22 @@
"current": null
}
}
},
{
"name": "message_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "message_invalid: Raw put messages must contain a document map.",
"routingHint": null,
"code": "message_invalid",
"details": {
"field": "document",
"value": null,
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -12,6 +12,7 @@ import msgpack
from cultnet_py import (
error_message,
message_invalid_error,
unsupported_schema_version_error,
CultNetRawSnapshotResponse,
CultNetSimulationObservationHub,
@ -602,12 +603,8 @@ class CultMeshLocalServer:
) -> list[dict[str, Any]]:
document_record = message.get("document")
if not isinstance(document_record, dict):
return [self._error_response(
"Raw put messages must contain a document map.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)]
malformed_shard = self._malformed_shard_fields(message, "malformed_document_put")
return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")]
malformed_shard = self._malformed_shard_fields(message)
if malformed_shard is not None:
return [malformed_shard]
# Refusal precedes every other judgment of the put. The node resolves the schema first, because the
@ -634,11 +631,7 @@ class CultMeshLocalServer:
# raises. It is refused to the peer that sent it; no thread and no other peer is
# affected. Neither the refusal nor the log quotes the error, which can quote the put.
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
return [self._error_response(
"Raw put document could not be stored.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)]
return [self._message_invalid(message, "document", "Raw put document could not be stored.")]
if change is None:
return [self._error_response(
"Raw put message did not apply to a registered document.",
@ -663,12 +656,10 @@ class CultMeshLocalServer:
schema_id = str(message.get("schemaId") or "")
record_key = str(message.get("recordKey") or "")
if not schema_id or not record_key:
return [self._error_response(
"Raw delete messages must contain schemaId and recordKey.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_delete",
return [self._message_invalid(
message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.",
)]
malformed_shard = self._malformed_shard_fields(message, "malformed_document_delete")
malformed_shard = self._malformed_shard_fields(message)
if malformed_shard is not None:
return [malformed_shard]
target = self.node.database.raw_delete_target(message) or (schema_id, record_key)
@ -684,7 +675,13 @@ class CultMeshLocalServer:
)]
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
def _malformed_shard_fields(self, message: dict[str, Any], code: str) -> dict[str, Any] | None:
def _message_invalid(self, message: dict[str, Any], field: str, reason: str) -> dict[str, Any]:
"""The typed `message_invalid` refusal naming the request field at fault, under the request's messageId."""
response = message_invalid_error(field, reason).to_wire()
response["messageId"] = str(message.get("messageId") or "")
return response
def _malformed_shard_fields(self, message: dict[str, Any]) -> dict[str, Any] | None:
"""The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is
not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can
read, and the store never files a write under a shard the authority did not see."""
@ -694,10 +691,10 @@ class CultMeshLocalServer:
shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool))
):
return None
return self._error_response(
return self._message_invalid(
message,
"shardId" if not (shard_id is None or isinstance(shard_id, str)) else "shardEpoch",
"shardId must be a string and shardEpoch an integer.",
message_id=str(message.get("messageId") or ""),
code=code,
)
def _refuse_unauthorized_write(
@ -929,11 +926,7 @@ class CultMeshLocalServer:
and isinstance(document_record.get("recordKey"), str)
and isinstance(document_record.get("payload"), (bytes, bytearray))
):
return self._error_response(
"Raw put documents must carry a string recordKey and a binary payload.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)
return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
if self.max_snapshot_bytes is None and not over_rudp:
return None
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)

View file

@ -4883,7 +4883,8 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertEqual(after["messageId"], "after")
self.assertEqual(after["documents"], [])
self.assertTrue(alive)
@ -4942,7 +4943,8 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in rudp_refusals + tcp_refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertNotIn("note:ok", refusal["error"], message_id)
self.assertEqual(len(tcp_refusals), 5)
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
@ -5056,9 +5058,11 @@ class CultMeshTests(unittest.TestCase):
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_put["code"], "message_invalid")
self.assertEqual(malformed_put["details"]["field"], "document")
self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
self.assertEqual(malformed_delete["code"], "message_invalid")
self.assertEqual(malformed_delete["details"]["field"], "recordKey")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")

View file

@ -311,15 +311,12 @@ class RemoteWriteAuthorityTests(unittest.TestCase):
self.seed(server, KEY_B, "server")
shards_before = server.node.database.shard_ids()
for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)):
for operation, code in (
("put", "malformed_document_put"),
("delete", "malformed_document_delete"),
):
for operation in ("put", "delete"):
with self.subTest(field=field, value=value, operation=operation):
message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B)
message[field] = value
replies = self.send(server, message)
self.assertEqual([reply["code"] for reply in replies], [code])
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", field)])
self.assertEqual(authority.requests, [], "the authority was never asked")
self.assertIsNone(self.text_of(server, KEY_A), "nothing was written")
self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted")

View file

@ -11,7 +11,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
- `createRudpDocumentServer` takes `writeAuthorityTimeoutMs` (default `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`, 5000; a value outside 1..2147483647 throws `RangeError`): a write authority whose `decide()` has not settled by then refuses the write like one that throws (`write_not_authorized`, and `onError` hears of it), so a hung authority no longer holds up the session's later frames.
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a `malformed_document_put` or `malformed_document_delete` refusal. A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `malformed_document_put`. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a typed `message_invalid` refusal (`details.field` is `message`). A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `message_invalid` naming that field. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
### Added

View file

@ -20,6 +20,7 @@ import {
CultNetRudpSocketTransportConnection,
rudpClientBindHost,
CultNetSchemaCatalog,
cultNetMessageInvalidError,
cultNetTextError,
cultNetWriteNotAuthorizedError,
CultNetShardCatalog,
@ -5667,7 +5668,7 @@ export class CultMesh {
if (decoded !== undefined && !write) throw error;
if (await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) return;
reportError(error);
sendSchemaMessage(record, malformedWriteError(write?.operation ?? "put", "CultMesh RUDP document server could not read the write."));
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
return;
}
switch (message.schemaVersion) {
@ -5716,7 +5717,10 @@ export class CultMesh {
malformedShard ? null : (shardEpoch as number | undefined) ?? null,
)) return;
if (malformedShard) {
sendSchemaMessage(record, malformedWriteError("put", "Raw put messages must carry a string shardId and an integer shardEpoch."));
sendSchemaMessage(record, cultNetMessageInvalidError(
typeof shardId !== "undefined" && typeof shardId !== "string" ? "shardId" : "shardEpoch",
"Raw put messages must carry a string shardId and an integer shardEpoch.",
));
return;
}
}
@ -5785,12 +5789,6 @@ export class CultMesh {
return undefined;
}
// Not a `code`: malformed_document_put/delete are outside the schema's closed code enum, which TS and Rust peers
// refuse to decode (docs/cultnet-error-contract-cut.md), so the name leads the text of a codeless error instead.
function malformedWriteError(operation: CultMeshRemoteWriteOperation, text: string): CultNetMessage {
return cultNetTextError(`malformed_document_${operation}: ${text}`);
}
/**
* Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and
* answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused.

View file

@ -316,7 +316,10 @@ test("wrong-typed shard fields on a put are judged with null shard fields, then
for (const fields of bad) r.peer.send(put(KEY_A, fields));
await r.settle();
assert.equal(r.replies.length, bad.length);
assert.ok(r.replies.every((reply) => reply.schemaVersion === "cultnet.error.v0" && reply.code === null && reply.error.startsWith("malformed_document_put:")));
assert.deepEqual(
r.replies.map((reply) => reply.schemaVersion === "cultnet.error.v0" ? [reply.code, reply.details?.field] : reply),
["shardId", "shardId", "shardEpoch", "shardEpoch", "shardEpoch"].map((field) => ["message_invalid", field]),
);
assert.equal(authority.requests.length, bad.length, "the authority is asked first");
assert.ok(authority.requests.every((q) => q.shardId === null && q.shardEpoch === null), "with null shard fields");
assert.equal(r.puts.length, 0);
@ -493,9 +496,9 @@ test("a granted peer's unreadable write reaches onError and gets a malformed ref
r.send(h.frame);
await sleep(250);
assert.equal(r.replies.length, 1, h.name);
const reply = r.replies[0] as { code: unknown; error: string };
assert.equal(reply.code, null, h.name);
assert.ok(reply.error.startsWith(`malformed_document_${h.operation}:`), h.name);
const reply = r.replies[0] as { code: unknown; error: string; details: { field: string } };
assert.equal(reply.code, "message_invalid", h.name);
assert.equal(reply.details.field, "message", h.name);
assert.equal(r.errors.length, 1, h.name);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], h.name);
assert.deepEqual([authority.requests.length, authority.requests[0]!.operation, authority.requests[0]!.recordKey], [1, h.operation, h.recordKey], h.name);

View file

@ -2,6 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor)
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
`tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C#

View file

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

View file

@ -44,6 +44,7 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
)
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
@ -185,6 +186,11 @@ def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage:
)
def message_invalid_error(field: str, reason: str) -> CultNetMessage:
"""The refusal for a request that lacks a required field or carries an ill-typed one (C# ForMessageInvalid)."""
return error_message(f"message_invalid: {reason}", code="message_invalid", details={"field": field})
def login_success(
*,
nonce: str,

View file

@ -22,6 +22,7 @@ CULTNET_ERROR_CODES = (
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
)
WIRE_MESSAGE_SCHEMA_VERSIONS = (

View file

@ -6,7 +6,7 @@ from pathlib import Path
import msgpack
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error
from cultnet_py import CULTNET_ERROR_CODES, error_message, message_invalid_error, 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).
@ -51,6 +51,10 @@ class CultNetErrorMessageTests(unittest.TestCase):
)
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"])
self.assertEqual(
message_invalid_error("document", "Raw put messages must contain a document map.").to_bytes(),
by_name["message_invalid"],
)
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
wire = error_message("boom").to_wire()

View file

@ -8,6 +8,7 @@ only in git history.
### Added
- `CultNetErrorCode::MessageInvalid` (`message_invalid`), the code for a request that lacks a required field or carries an ill-typed one; a captured-C#-bytes case pins it.
- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`.
- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`.
- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane.

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", "unsupported_schema_version"]
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
},
"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,7 +36,7 @@
"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\". unsupported_schema_version: \"schemaVersion\".", "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\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "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

@ -248,6 +248,7 @@ pub enum CultNetErrorCode {
UnownedSchema,
WriteNotAuthorized,
UnsupportedSchemaVersion,
MessageInvalid,
}
impl CultNetErrorCode {
@ -261,6 +262,7 @@ impl CultNetErrorCode {
Self::UnownedSchema => "unowned_schema",
Self::WriteNotAuthorized => "write_not_authorized",
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
Self::MessageInvalid => "message_invalid",
}
}
@ -274,6 +276,7 @@ impl CultNetErrorCode {
"unowned_schema" => Ok(Self::UnownedSchema),
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
"message_invalid" => Ok(Self::MessageInvalid),
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
}
}

View file

@ -188,6 +188,21 @@ fn cases() -> Vec<Case> {
}),
},
},
Case {
name: "message_invalid",
// C#: ForMessageInvalid("document", "Raw put messages must contain a document map.")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "message_invalid: Raw put messages must contain a document map.".to_string(),
code: Some(CultNetErrorCode::MessageInvalid),
details: Some(CultNetErrorDetails {
field: Some("document".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
]
}

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.

View file

@ -196,6 +196,7 @@ export const CULTNET_ERROR_CODES = [
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
] as const;
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
@ -219,6 +220,20 @@ export function cultNetWriteNotAuthorizedError(recordKey: string, reason?: strin
};
}
/**
* The refusal for a request that lacks a required field or carries an ill-typed one (C# `CultNetErrorMessage.ForMessageInvalid`):
* code `message_invalid`, `details.field` naming that request field.
*/
export function cultNetMessageInvalidError(field: string, reason: string): CultNetErrorMessage {
return {
schemaVersion: "cultnet.error.v0",
error: `message_invalid: ${reason}`,
routingHint: null,
code: "message_invalid",
details: { field, value: null, asOf: null, current: null },
};
}
export interface CultNetErrorRoutingHint {
shard: Record<string, unknown> | null;
reason: string;

View file

@ -18,6 +18,7 @@ export {
type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES,
cultNetTextError,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
type CultNetErrorCode,
type CultNetErrorDetails,

View file

@ -8,6 +8,7 @@ import { decode, encode } from "@msgpack/msgpack";
import {
CULTNET_ERROR_CODES,
cultNetSchemas,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
encodeCultNetMessageForWire,
parseCultNetMessage,
@ -89,6 +90,13 @@ test("the write_not_authorized factory writes the C# vector's bytes and names th
assert.deepEqual(cultNetWriteNotAuthorizedError("some:key", "not granted"), vector.message);
});
test("the message_invalid factory writes the C# vector's bytes and names the field", () => {
const vector = vectors.find((v) => v.name === "message_invalid")!;
const built = cultNetMessageInvalidError("document", "Raw put messages must contain a document map.");
assert.equal(hexOf(wireOf(built)), vector.messagePackHex);
assert.deepEqual(built, vector.message);
});
test("a write_not_authorized refusal with no reason, or a blank one, says only the code", () => {
assert.equal(cultNetWriteNotAuthorizedError("k").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", " ").error, "write_not_authorized");

View file

@ -401,7 +401,7 @@ namespace GameCult.Networking
: message.SubscriptionId;
if (string.IsNullOrWhiteSpace(subscriptionId))
{
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId."));
return Task.CompletedTask;
}
@ -443,7 +443,7 @@ namespace GameCult.Networking
: message.SubscriptionId;
if (string.IsNullOrWhiteSpace(subscriptionId))
{
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId."));
return Task.CompletedTask;
}

View file

@ -145,7 +145,7 @@ namespace GameCult.Networking
: request.SubscriptionId;
if (string.IsNullOrWhiteSpace(subscriptionId))
{
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId."));
return Task.CompletedTask;
}

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>, <c>unsupported_schema_version</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>, <c>message_invalid</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; }
@ -567,6 +567,18 @@ namespace GameCult.Networking
Details = new CultNetErrorDetails { Field = "schemaVersion", Value = schemaVersion }
};
/// <summary>
/// Builds the wire error for a request that lacks a required field or carries an ill-typed one: code
/// <c>message_invalid</c>, details <c>{ field }</c> naming that request field.
/// </summary>
public static CultNetErrorMessage ForMessageInvalid(string field, string reason) =>
new CultNetErrorMessage
{
Error = $"message_invalid: {reason}",
Code = "message_invalid",
Details = new CultNetErrorDetails { Field = field }
};
/// <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

@ -233,6 +233,19 @@ namespace GameCult.Networking.Tests
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0"));
}
[Test]
public void ErrorMessage_MessageInvalidRealBytes_MatchesItsSchema()
{
var message = CultNetErrorMessage.ForMessageInvalid("document", "Raw put messages must contain a document map.");
Assert.That(message.Code, Is.EqualTo("message_invalid"));
Assert.That(message.Details!.Field, Is.EqualTo("document"));
Assert.That(message.Details.Value, Is.Null);
AssertErrorMatchesSchema(message);
// The bytes cultnet-rs's error_message.rs message_invalid case decodes and re-encodes.
Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0"));
}
private static void AssertErrorMatchesSchema(CultNetErrorMessage message)
{
var validator = new MiniJsonSchemaValidator(SchemaDir());