From 662ae048d8621911cd0480eb862104660128bcbd Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:28:46 +0000 Subject: [PATCH 01/31] spec: cultnet write receipt (write_admitted.v0, error.v1 id echo, three-outcome clients) Specification and shared vectors only, no runtime code. A write is answered exactly once with cultnet.write_admitted.v0 or cultnet.error.v1 carrying its messageId; the new frames go only to connections whose hello lists them (today's TS decoder rejects unknown keys and versions, so "old clients ignore it" is false). Clients settle admitted | refused | unconfirmed; the result window is the pre-receipt fallback and ends unconfirmed. Wire vectors, schema-invalid cases and 16 replay scenarios in write-receipt-vectors.json. Co-Authored-By: Claude Sonnet 5.5 --- .../interop/cultnet.error.v1.schema.json | 121 +++ .../cultnet.write-admitted.schema.json | 16 + .../interop/write-receipt-vectors.json | 727 ++++++++++++++++++ contracts/cultnet/interop/write-receipt.md | 57 ++ 4 files changed, 921 insertions(+) create mode 100644 contracts/cultnet/interop/cultnet.error.v1.schema.json create mode 100644 contracts/cultnet/interop/cultnet.write-admitted.schema.json create mode 100644 contracts/cultnet/interop/write-receipt-vectors.json create mode 100644 contracts/cultnet/interop/write-receipt.md diff --git a/contracts/cultnet/interop/cultnet.error.v1.schema.json b/contracts/cultnet/interop/cultnet.error.v1.schema.json new file mode 100644 index 00000000..255cd6bb --- /dev/null +++ b/contracts/cultnet/interop/cultnet.error.v1.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json", + "title": "CultNet Error Message v1", + "description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum.", + "type": "object", + "required": [ + "schemaVersion", + "error", + "routingHint", + "code", + "details", + "messageId" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "cultnet.error.v1" + }, + "error": { + "type": "string", + "minLength": 1 + }, + "routingHint": { + "description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "required": [ + "shard", + "reason" + ], + "properties": { + "shard": { + "type": [ + "object", + "null" + ] + }, + "reason": { + "type": "string" + } + } + }, + "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", + "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.", + "type": [ + "object", + "null" + ], + "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\". 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 + } + } + }, + "messageId": { + "description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.", + "type": [ + "string", + "null" + ], + "minLength": 1 + } + } +} diff --git a/contracts/cultnet/interop/cultnet.write-admitted.schema.json b/contracts/cultnet/interop/cultnet.write-admitted.schema.json new file mode 100644 index 00000000..42e86a2e --- /dev/null +++ b/contracts/cultnet/interop/cultnet.write-admitted.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json", + "title": "CultNet Write Admitted", + "description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.", + "type": "object", + "required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultnet.write_admitted.v0" }, + "messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 }, + "operation": { "enum": ["put", "delete"] }, + "schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 }, + "recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 } + } +} diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json new file mode 100644 index 00000000..239fc897 --- /dev/null +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -0,0 +1,727 @@ +{ + "provenance": "Spec-authored with msgpack-python 1.2.4 (str keys in schema order, use_bin_type). Not yet a C# capture: the first conformance PR for each runtime re-captures these with its own encoder and the bytes must match, as error-vectors.json does. Every schema case is checked against cultnet.write-admitted.schema.json / cultnet.error.v1.schema.json in this directory.", + "specification": "write-receipt.md", + "wire": [ + { + "name": "write_admitted_put", + "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "put-1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "name": "write_admitted_delete", + "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "del-1", + "operation": "delete", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "name": "error_v1_write_not_authorized_echoes_id", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "write_not_authorized: the catalog refused the document", + "routingHint": null, + "code": "write_not_authorized", + "details": { + "field": "recordKey", + "value": "note:2", + "asOf": null, + "current": null + }, + "messageId": "put-2" + } + }, + { + "name": "error_v1_message_invalid_names_member_of_a_batch", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9256D6573736167655F696E76616C69643A207265636F72644B6579206973206D697373696E67AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964B062617463682D312D6D656D6265722D33", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid: recordKey is missing", + "routingHint": null, + "code": "message_invalid", + "details": { + "field": "recordKey", + "value": null, + "asOf": null, + "current": null + }, + "messageId": "batch-1-member-3" + } + }, + { + "name": "error_v1_unreadable_frame_has_null_message_id", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964C0", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid: unreadable frame", + "routingHint": null, + "code": "message_invalid", + "details": { + "field": "message", + "value": null, + "asOf": null, + "current": null + }, + "messageId": null + } + } + ], + "schemaInvalid": [ + { + "name": "write_admitted_with_an_error_key", + "messagePackHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A56572726F72A46E6F7065", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "put-1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1", + "error": "nope" + }, + "why": "admission must not carry an error" + }, + { + "name": "write_admitted_empty_message_id", + "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A0A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + }, + "why": "messageId is non-empty" + }, + { + "name": "write_admitted_unknown_operation", + "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA6757073657274A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "put-1", + "operation": "upsert", + "schemaId": "note.v0", + "recordKey": "note:1" + }, + "why": "operation is put or delete" + }, + { + "name": "write_admitted_null_record_key", + "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579C0", + "message": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "put-1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": null + }, + "why": "map-mode keys are never nil" + }, + { + "name": "error_v1_without_message_id", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72AF6D6573736167655F696E76616C6964AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid", + "routingHint": null, + "code": "message_invalid", + "details": { + "field": "message", + "value": null, + "asOf": null, + "current": null + } + }, + "why": "messageId is required (null when unknown), never omitted" + }, + { + "name": "error_v1_empty_message_id", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72AF6D6573736167655F696E76616C6964AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A0", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid", + "routingHint": null, + "code": "message_invalid", + "details": { + "field": "message", + "value": null, + "asOf": null, + "current": null + }, + "messageId": "" + }, + "why": "null, not empty string, means unknown" + }, + { + "name": "error_v0_is_not_error_v1", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B477726974655F6E6F745F617574686F72697A6564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A16BA461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": { + "field": "recordKey", + "value": "k", + "asOf": null, + "current": null + }, + "messageId": "put-2" + }, + "why": "v0 stays closed: a messageId key makes it invalid, which is why v1 exists" + } + ], + "scenarios": { + "windowMs": 500, + "semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. expect gives the settled outcome per write id. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.", + "cases": [ + { + "name": "receipt_admits", + "description": "A receipt with the id completes the write as admitted.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "transportAck": "w1" + }, + { + "atMs": 20, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + }, + { + "name": "receipt_before_transport_ack", + "description": "Order against the transport ack is irrelevant.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 9, + "transportAck": "w1" + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + }, + { + "name": "refusal_after_ack", + "description": "A typed refusal after the transport ack refuses; the ack was not admission.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "transportAck": "w1" + }, + { + "atMs": 120, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null, + "messageId": "w1" + } + } + ], + "expect": { + "w1": { + "outcome": "refused", + "code": "write_not_authorized" + } + } + }, + { + "name": "refusal_before_ack", + "description": "A typed refusal before the transport ack refuses.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid", + "routingHint": null, + "code": "message_invalid", + "details": null, + "messageId": "w1" + } + }, + { + "atMs": 9, + "transportAck": "w1" + } + ], + "expect": { + "w1": { + "outcome": "refused", + "code": "message_invalid" + } + } + }, + { + "name": "ack_only_window_ends_unconfirmed", + "description": "The legacy fallback: an acked write that nothing answered ends unconfirmed, never admitted.", + "server": "legacy", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "transportAck": "w1" + }, + { + "atMs": 500, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "window_runs_from_send_not_from_ack", + "description": "A late transport ack does not extend the window.", + "server": "legacy", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 450, + "transportAck": "w1" + }, + { + "atMs": 500, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "silent_receipt_capable_server_is_unconfirmed_too", + "description": "A server that advertised receipts and stayed silent is still only unconfirmed, and the client reports the fault.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "transportAck": "w1" + }, + { + "atMs": 5000, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "legacy_error_with_one_write_in_flight_refuses", + "description": "A pre-receipt server answers with error.v0 and no id: attributable only because one write is unanswered.", + "server": "legacy", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 30, + "frame": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null + } + } + ], + "expect": { + "w1": { + "outcome": "refused", + "code": "write_not_authorized" + } + } + }, + { + "name": "legacy_error_with_two_writes_in_flight_is_unattributed", + "description": "An id-less error with two unanswered writes names neither: both stay pending and end unconfirmed.", + "server": "legacy", + "writes": [ + "w1", + "w2" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 1, + "send": "w2" + }, + { + "atMs": 30, + "frame": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null + } + }, + { + "atMs": 501, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + }, + "w2": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "batch_names_the_refused_member", + "description": "A batch is independent writes. The refusal names its member; the others keep their own outcomes.", + "server": "receipt", + "writes": [ + "b1", + "b2", + "b3" + ], + "events": [ + { + "atMs": 0, + "send": "b1" + }, + { + "atMs": 0, + "send": "b2" + }, + { + "atMs": 0, + "send": "b3" + }, + { + "atMs": 10, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "b1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 11, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null, + "messageId": "b2" + } + }, + { + "atMs": 12, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "b3", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "b1": { + "outcome": "admitted" + }, + "b2": { + "outcome": "refused", + "code": "write_not_authorized" + }, + "b3": { + "outcome": "admitted" + } + } + }, + { + "name": "batch_with_one_member_unanswered", + "description": "A member nothing answered is unconfirmed; the batch as a whole is admitted only if every member is.", + "server": "receipt", + "writes": [ + "b1", + "b2" + ], + "events": [ + { + "atMs": 0, + "send": "b1" + }, + { + "atMs": 0, + "send": "b2" + }, + { + "atMs": 10, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "b1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 5000, + "tick": true + } + ], + "expect": { + "b1": { + "outcome": "admitted" + }, + "b2": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "session_ends_before_an_answer", + "description": "A lost session leaves unanswered writes unconfirmed, not failed and not admitted.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "transportAck": "w1" + }, + { + "atMs": 40, + "sessionEnd": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "session_ended" + } + } + }, + { + "name": "late_refusal_does_not_rewrite_the_outcome", + "description": "An answer after the window changes nothing the caller already got; it is logged.", + "server": "legacy", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 500, + "tick": true + }, + { + "atMs": 700, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null, + "messageId": "w1" + } + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "duplicate_resend_of_an_admitted_write_is_admitted_again", + "description": "The server answers a re-sent admitted messageId with the receipt again, never an error.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 20, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 30, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + }, + { + "name": "an_answer_for_an_unknown_id_is_ignored", + "description": "A frame naming no write in flight settles nothing.", + "server": "receipt", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 10, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "other", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 500, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + } + ] + } +} diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md new file mode 100644 index 00000000..4d1517bd --- /dev/null +++ b/contracts/cultnet/interop/write-receipt.md @@ -0,0 +1,57 @@ +# CultNet write receipt (specification) + +Status: specification. No runtime implements it yet; the conformance follow-ups below do. +Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission). +Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them. + +## The problem + +A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer). + +## Frames + +**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write. + +**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id. + +Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders. + +## Capability: opting in + +A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell silence from a legacy server from a receipt server's fault. A client that sends no hello gets legacy behaviour. + +## Server rules + +1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers. +2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission. +3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read. +4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer. +5. **Idempotent resend.** A resend of the `messageId` of an already admitted write is answered with `write_admitted` again, never with an error. A resend of a refused write is answered with the same refusal. +6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server sends nothing; the client's own rule below settles it as unconfirmed. A forwarder that cannot tell admitted from refused is a fault, never an admission. +7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. + +## Client rules: three outcomes + +A write settles as exactly one of: + +- **admitted**: a `write_admitted` whose `messageId` is the write's. +- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. +- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send*, default 500 ms, passed with no answer) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted and decides whether to read the record back or resend (resend is safe under server rule 5). + +The result window is the *fallback* for a server that predates receipts. Against a receipt-capable server (its hello lists the frames) clients SHOULD wait longer than 500 ms (5 s suggested) and SHOULD report a silent receipt-capable server as a server fault; the outcome is still unconfirmed. + +**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. Late answers (after a write settled) never rewrite the outcome; log them. A frame naming a `messageId` that is not in flight settles nothing. + +A batch is the set of its members' outcomes: admitted only if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). + +## Changes to what #55 to #58 shipped + +Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The conformance work changes it; until then the window-success is the interim, and callers must not read it as admission. + +## Vectors + +`write-receipt-vectors.json`: `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract, `windowMs` is 500). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7. + +## Conformance follow-ups (not in this PR) + +C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog. From ab7bc771e379e98d84ea655db5c27a8802300b85 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:31:49 +0000 Subject: [PATCH 02/31] spec: write receipt per Heimdall's review (dedupe by actor+content hash, admitted-late, 5 s window, capability is framing only) - messageId unique per authenticated actor and session; dedupe key (actor, id) bound to SHA-256 of [operation, schemaId, recordKey, payload]; same id with other content is message_invalid, never the old receipt. - A late answer after the window is recorded on the write (admitted_late / refused_late); no blind resend. - A forwarder with no primary answer relays nothing; the client reports unconfirmed. - RECEIPT_RESULT_WINDOW_MS 5000 (default), LEGACY_RESULT_WINDOW_MS 500 (fallback only), DEDUPE_RETENTION_MS as vector constants. - The hello capability changes framing only and never authorizes. - Batches can commit partially; the caller owns retry. - Vectors: constants, worked content hashes, 7 dedupe cases, late-answer and window scenarios. Co-Authored-By: Claude Sonnet 5.5 --- .../interop/write-receipt-vectors.json | 339 ++++++++++++++++-- contracts/cultnet/interop/write-receipt.md | 24 +- 2 files changed, 335 insertions(+), 28 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index 239fc897..93bfa4f3 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -181,8 +181,7 @@ } ], "scenarios": { - "windowMs": 500, - "semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. expect gives the settled outcome per write id. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.", + "semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. 'window' names the constant in vectors.constants that applies (server receipt -> RECEIPT_RESULT_WINDOW_MS, legacy -> LEGACY_RESULT_WINDOW_MS); a tick at or after the window settles unanswered writes as unconfirmed. expect gives the outcome the caller was told per write id; recorded, when present, is the write handle's later state. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.", "cases": [ { "name": "receipt_admits", @@ -215,7 +214,8 @@ "w1": { "outcome": "admitted" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "receipt_before_transport_ack", @@ -248,7 +248,8 @@ "w1": { "outcome": "admitted" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "refusal_after_ack", @@ -283,7 +284,8 @@ "outcome": "refused", "code": "write_not_authorized" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "refusal_before_ack", @@ -318,7 +320,8 @@ "outcome": "refused", "code": "message_invalid" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "ack_only_window_ends_unconfirmed", @@ -346,7 +349,8 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } - } + }, + "window": "LEGACY_RESULT_WINDOW_MS" }, { "name": "window_runs_from_send_not_from_ack", @@ -374,7 +378,8 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } - } + }, + "window": "LEGACY_RESULT_WINDOW_MS" }, { "name": "silent_receipt_capable_server_is_unconfirmed_too", @@ -402,7 +407,8 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "legacy_error_with_one_write_in_flight_refuses", @@ -432,7 +438,8 @@ "outcome": "refused", "code": "write_not_authorized" } - } + }, + "window": "LEGACY_RESULT_WINDOW_MS" }, { "name": "legacy_error_with_two_writes_in_flight_is_unattributed", @@ -475,7 +482,8 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } - } + }, + "window": "LEGACY_RESULT_WINDOW_MS" }, { "name": "batch_names_the_refused_member", @@ -542,7 +550,8 @@ "b3": { "outcome": "admitted" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "batch_with_one_member_unanswered", @@ -584,7 +593,8 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "session_ends_before_an_answer", @@ -612,11 +622,12 @@ "outcome": "unconfirmed", "reason": "session_ended" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { - "name": "late_refusal_does_not_rewrite_the_outcome", - "description": "An answer after the window changes nothing the caller already got; it is logged.", + "name": "late_refusal_is_recorded_not_told", + "description": "An answer after the window does not change what the caller was told; the handle records refused_late.", "server": "legacy", "writes": [ "w1" @@ -645,9 +656,11 @@ "expect": { "w1": { "outcome": "unconfirmed", - "reason": "window_elapsed" + "reason": "window_elapsed", + "recorded": "refused_late" } - } + }, + "window": "LEGACY_RESULT_WINDOW_MS" }, { "name": "duplicate_resend_of_an_admitted_write_is_admitted_again", @@ -686,7 +699,8 @@ "w1": { "outcome": "admitted" } - } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" }, { "name": "an_answer_for_an_unknown_id_is_ignored", @@ -711,7 +725,7 @@ } }, { - "atMs": 500, + "atMs": 5000, "tick": true } ], @@ -720,7 +734,292 @@ "outcome": "unconfirmed", "reason": "window_elapsed" } + }, + "window": "RECEIPT_RESULT_WINDOW_MS" + }, + { + "name": "late_receipt_is_recorded_admitted_late", + "description": "A receipt after the window: the caller was told unconfirmed, the handle records admitted_late; a caller must consult it before resending.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5000, + "tick": true + }, + { + "atMs": 6200, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed", + "recorded": "admitted_late" + } } + }, + { + "name": "receipt_inside_the_receipt_window_is_not_late", + "description": "The receipt-capable window is 5 s: a receipt at 1.2 s admits.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 1200, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + }, + { + "name": "legacy_window_is_not_used_against_a_receipt_server", + "description": "At 500 ms a receipt-capable server's write is still pending, not unconfirmed.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 500, + "tick": true + }, + { + "atMs": 800, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + } + ] + }, + "constants": { + "RECEIPT_RESULT_WINDOW_MS": 5000, + "LEGACY_RESULT_WINDOW_MS": 500, + "DEDUPE_RETENTION_MS": 600000 + }, + "contentHash": { + "definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase", + "cases": [ + { + "name": "put_note_1_payload_a", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1", + "payloadHex": "81A26964A131", + "sha256": "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d" + }, + { + "name": "put_note_1_payload_b", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1", + "payloadHex": "81A26964A132", + "sha256": "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f" + }, + { + "name": "put_note_2_payload_a", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:2", + "payloadHex": "81A26964A131", + "sha256": "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8" + }, + { + "name": "delete_note_1", + "operation": "delete", + "schemaId": "note.v0", + "recordKey": "note:1", + "payloadHex": null, + "sha256": "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37" + } + ] + }, + "dedupe": { + "semantics": "Replay writes in order against a receipt-capable server whose actors are transport-authenticated. expect lists the answer per write in order and how many times a write was applied to the store in total. 'invalid' means error.v1 message_invalid with details.field messageId and the write's id.", + "cases": [ + { + "name": "resend_same_content_re_admits_without_reapplying", + "writes": [ + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + } + ], + "answers": [ + "admitted", + "admitted" + ], + "applied": 1 + }, + { + "name": "same_id_different_payload_is_refused_not_re_receipted", + "writes": [ + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_b" + } + ], + "answers": [ + "admitted", + "refused:message_invalid" + ], + "applied": 1 + }, + { + "name": "same_id_different_record_key_is_refused", + "writes": [ + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_2_payload_a" + } + ], + "answers": [ + "admitted", + "refused:message_invalid" + ], + "applied": 1 + }, + { + "name": "same_id_different_operation_is_refused", + "writes": [ + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "alice", + "messageId": "m1", + "content": "delete_note_1" + } + ], + "answers": [ + "admitted", + "refused:message_invalid" + ], + "applied": 1 + }, + { + "name": "the_same_id_from_another_actor_is_an_independent_write", + "writes": [ + { + "actor": "alice", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "bob", + "messageId": "m1", + "content": "put_note_1_payload_b" + } + ], + "answers": [ + "admitted", + "admitted" + ], + "applied": 2 + }, + { + "name": "resend_of_a_refused_write_is_refused_the_same_way", + "writes": [ + { + "actor": "mallory", + "messageId": "m1", + "content": "put_note_1_payload_a" + }, + { + "actor": "mallory", + "messageId": "m1", + "content": "put_note_1_payload_a" + } + ], + "answers": [ + "refused:write_not_authorized", + "refused:write_not_authorized" + ], + "applied": 0, + "note": "mallory is not authorized to write note.v0" + }, + { + "name": "the_capability_does_not_authorize", + "writes": [ + { + "actor": "mallory", + "messageId": "m2", + "content": "put_note_2_payload_a" + } + ], + "helloDeclaresReceipts": true, + "answers": [ + "refused:write_not_authorized" + ], + "applied": 0, + "note": "declaring the receipt capability changes the frame the refusal arrives in, never whether it is a refusal" } ] } diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 4d1517bd..86f9f230 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -18,7 +18,9 @@ Why a new error version and not an extra key on v0: the v0 schema is closed (`ad ## Capability: opting in -A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell silence from a legacy server from a receipt server's fault. A client that sends no hello gets legacy behaviour. +A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. + +**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in. ## Server rules @@ -26,8 +28,12 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir 2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission. 3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read. 4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer. -5. **Idempotent resend.** A resend of the `messageId` of an already admitted write is answered with `write_admitted` again, never with an error. A resend of a refused write is answered with the same refusal. -6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server sends nothing; the client's own rule below settles it as unconfirmed. A forwarder that cannot tell admitted from refused is a fault, never an admission. +5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). It is retained at least `DEDUPE_RETENTION_MS` (10 minutes) and the life of the session. + - Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice. + - Same key, same content hash, answer was a refusal: the same refusal again. + - **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged. + - Past retention a resend is a new write. +6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. 7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. ## Client rules: three outcomes @@ -36,13 +42,15 @@ A write settles as exactly one of: - **admitted**: a `write_admitted` whose `messageId` is the write's. - **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. -- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send*, default 500 ms, passed with no answer) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted and decides whether to read the record back or resend (resend is safe under server rule 5). +- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content is safe against a receipt-capable server (rule 5); against a legacy server a resend may apply the write twice, so the caller reads the record back first. -The result window is the *fallback* for a server that predates receipts. Against a receipt-capable server (its hello lists the frames) clients SHOULD wait longer than 500 ms (5 s suggested) and SHOULD report a silent receipt-capable server as a server fault; the outcome is still unconfirmed. +**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window. -**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. Late answers (after a write settled) never rewrite the outcome; log them. A frame naming a `messageId` that is not in flight settles nothing. +**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. After a resend with the same id and content, rule 5 makes a duplicate harmless on a receipt-capable server. -A batch is the set of its members' outcomes: admitted only if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). +**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing. + +**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids. ## Changes to what #55 to #58 shipped @@ -50,7 +58,7 @@ Those clients complete *successfully* when the window ends with no refusal. Unde ## Vectors -`write-receipt-vectors.json`: `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract, `windowMs` is 500). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7. +`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7. ## Conformance follow-ups (not in this PR) From 9f73047c29a0154f98215d0787620a3aaef579d8 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:38:46 +0000 Subject: [PATCH 03/31] cultmesh-rs: RUDP document server answers writes with write_admitted / error.v1 (write-receipt spec) cultnet-rs gains cultnet.write_admitted.v0, cultnet.error.v1 (v0 + messageId) and write_content_hash. The server opts a session in only when its hello lists both frames (framing only; admission rules are unchanged). On an opted-in session an admitted put is answered with a receipt carrying its id after the sink accepts it, a refusal is an error.v1 naming the id, a resend of the same content is re-receipted without reaching the sink, and the same id with other content is refused message_invalid (never the old receipt). Other sessions are answered exactly as before. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 152 +++++++++++++++- .../cultmesh-rs/tests/rudp_document_server.rs | 162 ++++++++++++++++++ packages/cultnet-rs/src/contracts.rs | 111 +++++++++++- packages/cultnet-rs/tests/write_receipt.rs | 52 ++++++ 4 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 packages/cultnet-rs/tests/write_receipt.rs diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 647589e0..b7d97d77 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,9 +3,9 @@ use cultnet_rs::{ CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, send_error_code, + encode_rudp_packet, is_permanent_send_error, send_error_code, write_content_hash, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; #[cfg(test)] use std::collections::BTreeSet; use std::io::ErrorKind; @@ -258,6 +258,9 @@ pub enum CultMeshRudpRejectionReason { DeleteNotSupported, /// A schema frame did not decode as a CultNet message. Carries the decoder's error text. MessageUnreadable(String), + /// A receipt-capable session sent a message id it already used for a write with other content + /// (write-receipt.md, rule 5). Nothing reached the sink; the first write's state is unchanged. + MessageIdReused, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -266,6 +269,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error), Self::DeleteNotSupported => f.write_str("the server does not take deletes"), Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"), + Self::MessageIdReused => f.write_str("the message id was already used for different content"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -306,7 +310,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 11] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 12] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -318,6 +322,7 @@ pub(crate) const REFUSAL_TEXTS: [&str; 11] = [ "the snapshot response could not be sent", "the server does not take deletes", "the message could not be read", + "the message id was already used for different content", ]; impl CultMeshRudpRejectionReason { @@ -335,6 +340,7 @@ impl CultMeshRudpRejectionReason { Self::ResponseSendFailed(_) => 8, Self::DeleteNotSupported => 9, Self::MessageUnreadable(_) => 10, + Self::MessageIdReused => 11, }] } } @@ -384,7 +390,23 @@ impl CultMeshRudpApplicationRejection { /// What the refused peer is sent: a refused put or delete is a typed `write_not_authorized` naming /// the record, an unreadable frame a typed `message_invalid` naming `message`; every other rejection /// is the variant's fixed text, untyped. None carries the sink's, source's or decoder's own text. - fn refusal_message(&self) -> CultNetMessage { + /// + /// A receipt-capable session is sent `cultnet.error.v1` naming the refused message's id; any other + /// session the v0 error it has always been sent. + fn refusal_message(&self, receipts: bool) -> CultNetMessage { + let refusal = self.v0_refusal_message(); + match refusal { + CultNetMessage::Error { error, code, details } if receipts => CultNetMessage::ErrorV1 { + error, + code, + details, + message_id: Some(self.message_id.clone()).filter(|id| !id.is_empty()), + }, + other => other, + } + } + + fn v0_refusal_message(&self) -> CultNetMessage { let text = self.reason.refusal_text(); match (&self.reason, &self.record_key) { ( @@ -395,6 +417,9 @@ impl CultMeshRudpApplicationRejection { (CultMeshRudpRejectionReason::MessageUnreadable(_), _) => { message_invalid_message("message", text) } + (CultMeshRudpRejectionReason::MessageIdReused, _) => { + message_invalid_message("messageId", text) + } _ => CultNetMessage::Error { error: text.into(), code: None, @@ -431,8 +456,18 @@ struct SessionEntry { /// Set once the peer's capped frame has been answered: the session ends when the answer is acknowledged, /// or at this time. ending_deadline_monotonic_millis: Option, + /// The peer's hello listed `cultnet.write_admitted.v0` and `cultnet.error.v1`: its writes are answered + /// with a receipt or an `error.v1` naming the write's id (write-receipt.md). Framing only; it grants nothing. + receipts: bool, + /// Admitted writes of a receipt-capable session: message id -> content hash, so a resend of the same + /// content is re-receipted without being applied again and the same id with other content is refused. + admitted_writes: HashMap, } +/// The most admitted writes a receipt-capable session remembers; a session at the limit refuses new writes +/// (`PayloadBudgetFull`) rather than forget an id it promised to answer idempotently. +const MAX_REMEMBERED_WRITES_PER_SESSION: usize = 1024; + /// A synchronous, multi-session CultNet RUDP raw-document server. /// /// The server owns exactly one UDP socket. Call `poll_once` from an existing @@ -687,7 +722,7 @@ where if (is_unreadable || is_delete) && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES) { - let refusal = rejection.refusal_message(); + let refusal = rejection.refusal_message(self.receipts(key)); let packets = match self.sessions.remove(&key) { Some(mut entry) => { let packets = self.refusal_packets(&mut entry.session, &refusal, false); @@ -715,7 +750,7 @@ where CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - let refusal = rejection.refusal_message(); + let refusal = rejection.refusal_message(self.receipts(key)); let packets = match self.sessions.remove(&key) { Some(mut entry) => { let packets = self.refusal_packets(&mut entry.session, &refusal, true); @@ -752,7 +787,7 @@ where rejection.reason, CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - self.end_session(key, SessionEnd::Rejected(rejection.refusal_message()))?; + self.end_session(key, SessionEnd::Rejected(rejection.refusal_message(self.receipts(key))))?; } return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } @@ -892,6 +927,8 @@ where undecodable_frames_in_a_row: 0, refused_deletes_in_a_row: 0, ending_deadline_monotonic_millis: None, + receipts: false, + admitted_writes: HashMap::new(), }, ); accept @@ -927,6 +964,39 @@ where reason, })) }; + let content_hash = match write_content_hash( + "put", + &document.schema_id, + &document.record_key, + Some(&document.payload), + ) { + Ok(hash) => hash, + Err(error) => { + return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed( + format!("{error:#}"), + )); + } + }; + let remembered = self + .sessions + .get(&key) + .filter(|entry| entry.receipts) + .map(|entry| (entry.admitted_writes.get(&message_id).copied(), entry.admitted_writes.len())); + if let Some((prior, remembered_count)) = remembered { + match prior { + Some(prior) if prior == content_hash => { + self.send_write_admitted(key, now, &message_id, &document.schema_id, &document.record_key); + return Ok(None); + } + Some(_) => return reject(CultMeshRudpRejectionReason::MessageIdReused), + None if remembered_count >= MAX_REMEMBERED_WRITES_PER_SESSION => { + return reject(CultMeshRudpRejectionReason::PayloadBudgetFull); + } + None => {} + } + } + let (receipt_schema_id, receipt_record_key) = + (document.schema_id.clone(), document.record_key.clone()); // Admit only what some snapshot request can return: the smallest // response that could carry this document is the record the snapshot // source would serve for it, alone, under the shortest message id @@ -974,6 +1044,25 @@ where "{error:#}" ))); } + // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. + if let Some(entry) = self.sessions.get_mut(&key).filter(|entry| entry.receipts) { + entry.admitted_writes.insert(message_id.clone(), content_hash); + self.send_write_admitted(key, now, &message_id, &receipt_schema_id, &receipt_record_key); + } + } + CultNetMessage::Hello { + supported_message_versions, + .. + } => { + let listed = |version: &str| { + supported_message_versions + .iter() + .flatten() + .any(|candidate| candidate == version) + }; + if let Some(entry) = self.sessions.get_mut(&key) { + entry.receipts = listed("cultnet.write_admitted.v0") && listed("cultnet.error.v1"); + } } CultNetMessage::SnapshotRequest { message_id, @@ -1114,6 +1203,51 @@ where Ok(None) } + /// Sends the receipt for an admitted put. Best effort: the write is already committed, so a receipt that + /// cannot be queued or sent is dropped and the peer's own window settles the write as unconfirmed. It is + /// never answered with a refusal, which would make an admitted write look refused. + fn send_write_admitted( + &mut self, + key: CultMeshRudpSessionKey, + now: u64, + message_id: &str, + schema_id: &str, + record_key: &str, + ) { + let receipt = CultNetMessage::WriteAdmitted { + message_id: message_id.into(), + operation: "put".into(), + schema_id: schema_id.into(), + record_key: record_key.into(), + }; + let Ok(payload) = encode_cultnet_message_to_vec(&receipt, CultNetWireContract::CultNetSchemaV0) else { + return; + }; + let max_fragment_bytes = self.options.max_fragment_bytes; + let Some(entry) = self.sessions.get_mut(&key) else { + return; + }; + let Ok(packets) = entry.session.send_many( + "schema", + payload, + CultNetRudpSendOptions { + reliable: true, + ordered: true, + sequenced: false, + now_ms: now, + reliable_expire_after_ms: None, + }, + Some(max_fragment_bytes), + ) else { + return; + }; + for packet in &packets { + if !matches!(self.send_packet(key.remote_addr, packet), Ok(None)) { + break; + } + } + } + /// Ends a session before its peer is done with it, and says goodbye. It is /// the one way a session ends early: every end removes the session, sends /// what the peer is owed, then the goodbye. @@ -1155,6 +1289,10 @@ where } /// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response. + fn receipts(&self, key: CultMeshRudpSessionKey) -> bool { + self.sessions.get(&key).is_some_and(|entry| entry.receipts) + } + fn refusal_packets( &self, session: &mut CultNetRudpSession, diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 0e3cba66..7eb6fcdc 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2056,3 +2056,165 @@ fn the_grace_deadline_does_not_wrap_at_the_end_of_the_clock() -> Result<()> { assert_eq!(server.session_count(), 1); Ok(()) } + +// Write receipts (contracts/cultnet/interop/write-receipt.md). A connection whose hello lists both new frames is +// answered with `write_admitted.v0` or `error.v1`, each carrying the write's id; any other connection is answered +// exactly as before. + +fn receipt_hello() -> CultNetMessage { + CultNetMessage::Hello { + runtime_id: "receipt-client".into(), + runtime_kind: "test".into(), + agent_id: None, + role: None, + display_name: None, + supported_document_types: None, + supported_mutation_contracts: None, + supported_message_versions: Some(vec![ + "cultnet.write_admitted.v0".into(), + "cultnet.error.v1".into(), + ]), + transport_profiles: None, + supports_schema_catalog: None, + } +} + +fn put(id: &str, key: &str, payload: Vec) -> CultNetMessage { + CultNetMessage::DocumentPutRaw { + message_id: id.into(), + document: document(key, payload), + } +} + +/// Polls the server until `client` has `count` schema frames or the polling runs out; rejections are expected here. +fn frames_after( + server: &mut CultMeshRudpDocumentServer, + client: &mut CultNetRudpSocketTransportConnection, + count: usize, +) -> Result> { + let mut frames = Vec::new(); + for _ in 0..600 { + server.poll_once()?; + client.poll_resends()?; + while let Some(frame) = client.receive_once()? { + if frame.channel_id == "schema" { + frames.push(decode_cultnet_message_from_slice( + &frame.payload, + CultNetWireContract::CultNetSchemaV0, + )?); + } + } + if frames.len() >= count { + break; + } + thread::sleep(Duration::from_millis(1)); + } + Ok(frames) +} + +fn admitted(id: &str, key: &str) -> CultNetMessage { + CultNetMessage::WriteAdmitted { + message_id: id.into(), + operation: "put".into(), + schema_id: "test.raw.v1".into(), + record_key: key.into(), + } +} + +#[test] +fn an_admitted_put_on_a_receipt_connection_is_answered_with_a_receipt_carrying_its_id() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 201)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + Ok(()) +} + +#[test] +fn a_connection_without_the_capability_gets_no_receipt() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 202)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![]); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the put was admitted all the same"); + Ok(()) +} + +#[test] +fn a_resend_of_an_admitted_write_is_re_receipted_without_applying_it_twice() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 203)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; + assert_eq!( + frames_after(&mut server, &mut peer, 2)?, + vec![admitted("m1", "note:1"), admitted("m1", "note:1")] + ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + Ok(()) +} + +#[test] +fn the_same_id_with_other_content_is_refused_message_invalid_never_re_receipted() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 204)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; + send_reliable(&mut peer, &put("m1", "note:1", vec![9, 9, 9]))?; + let frames = frames_after(&mut server, &mut peer, 2)?; + assert_eq!(frames[0], admitted("m1", "note:1")); + let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &frames[1] else { + panic!("the second answer must be a refusal, got {:?}", frames[1]); + }; + assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); + assert_eq!(message_id.as_deref(), Some("m1")); + assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the changed write was not applied"); + Ok(()) +} + +#[test] +fn a_refused_put_on_a_receipt_connection_is_a_typed_error_naming_its_id() -> Result<()> { + let sink = Sink::fail_once(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 205)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m7", "note:7", vec![1]))?; + let frames = frames_after(&mut server, &mut peer, 1)?; + let Some(CultNetMessage::ErrorV1 { code, message_id, .. }) = frames.first() else { + panic!("expected error.v1, got {frames:?}"); + }; + assert_eq!(*code, Some(CultNetErrorCode::WriteNotAuthorized)); + assert_eq!(message_id.as_deref(), Some("m7")); + Ok(()) +} + +#[test] +fn a_hello_listing_only_one_of_the_two_frames_does_not_opt_in() -> Result<()> { + for listed in ["cultnet.write_admitted.v0", "cultnet.error.v1"] { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 206)?; + connect(&mut server, &mut [&mut peer])?; + let mut hello = receipt_hello(); + if let CultNetMessage::Hello { supported_message_versions, .. } = &mut hello { + *supported_message_versions = Some(vec![listed.into()]); + } + send_reliable(&mut peer, &hello)?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![], "listed only {listed}"); + } + Ok(()) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index ebc32afa..cc9a9c34 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -359,6 +359,26 @@ pub enum CultNetMessage { #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, + /// `cultnet.error.v1` (contracts/cultnet/interop/write-receipt.md): `cultnet.error.v0` plus the id of the + /// request it answers. Sent only to a connection that declared the write-receipt capability. + #[serde(skip_deserializing)] + #[serde(rename = "cultnet.error.v1", rename_all = "camelCase")] + ErrorV1 { + error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + details: Option, + message_id: Option, + }, + /// `cultnet.write_admitted.v0`: the receipt for one admitted put or delete, sent after its commit point. + #[serde(rename = "cultnet.write_admitted.v0", rename_all = "camelCase")] + WriteAdmitted { + message_id: String, + operation: String, + schema_id: String, + record_key: String, + }, #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] SampleChangeName { name: String }, #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] @@ -543,7 +563,8 @@ pub fn encode_cultnet_message_for_wire( | CultNetMessage::SnapshotResponseRawV1 { .. } | CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } - | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), + | CultNetMessage::Error { .. } + | CultNetMessage::ErrorV1 { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec( &serde_json::to_value(message)?, )?)?), @@ -573,7 +594,8 @@ pub fn decode_cultnet_message_from_slice( | "cultnet.snapshot_response_raw.v0" | "cultmesh.content_chunk_request.v1" | "cultmesh.content_chunk_response.v1" - | "cultnet.error.v0", + | "cultnet.error.v0" + | "cultnet.error.v1", ) => parse_raw_cultnet_schema_message(&wire_value) .with_context(|| received_version_label(&wire_value))?, _ => cultcache_rs::decode_payload::(bytes) @@ -645,6 +667,20 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { require_non_empty(session, "session")?; } CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?, + CultNetMessage::ErrorV1 { error, message_id, .. } => { + require_non_empty(error, "error")?; + if let Some(message_id) = message_id { + require_non_empty(message_id, "messageId")?; + } + } + CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => { + require_non_empty(message_id, "messageId")?; + if operation != "put" && operation != "delete" { + return Err(anyhow!("operation must be put or delete")); + } + require_non_empty(schema_id, "schemaId")?; + require_non_empty(record_key, "recordKey")?; + } CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::DocumentDelete { @@ -1021,7 +1057,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result { + "cultnet.error.v0" | "cultnet.error.v1" => { let code = get("code") .filter(|value| !value.is_nil()) .map(|value| { @@ -1032,11 +1068,19 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result None, + other => Some(require_legacy_string(other, "messageId")?), + }, + }); + } + Ok(CultNetMessage::Error { error, code, details }) } _ => Err(anyhow!( "Unsupported raw cultnet.schema.v0 message" @@ -1255,6 +1299,36 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.error.v1")), + (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), + (rmpv::Value::from("routingHint"), rmpv::Value::Nil), + ( + rmpv::Value::from("code"), + code.as_ref() + .map(|code| rmpv::Value::from(code.as_wire_str())) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("details"), + details + .as_ref() + .map(encode_error_details) + .unwrap_or(rmpv::Value::Nil), + ), + ( + rmpv::Value::from("messageId"), + message_id + .as_deref() + .map(rmpv::Value::from) + .unwrap_or(rmpv::Value::Nil), + ), + ], _ => { return Err(anyhow!( "Message is not a raw cultnet.schema.v0 binary replication message" @@ -1884,3 +1958,24 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result, +) -> Result<[u8; 32]> { + use sha2::{Digest, Sha256}; + let value = rmpv::Value::Array(vec![ + rmpv::Value::from(operation), + rmpv::Value::from(schema_id), + rmpv::Value::from(record_key), + payload.map_or(rmpv::Value::Nil, |payload| rmpv::Value::Binary(payload.to_vec())), + ]); + let mut bytes = Vec::new(); + rmpv::encode::write_value(&mut bytes, &value)?; + Ok(Sha256::digest(&bytes).into()) +} diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs new file mode 100644 index 00000000..686c3bbe --- /dev/null +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -0,0 +1,52 @@ +//! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames. + +use cultnet_rs::{ + CultNetErrorCode, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + encode_cultnet_message_to_vec, write_content_hash, +}; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[test] +fn the_content_hash_matches_the_spec_vectors() { + let a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; + let b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; + let cases = [ + (write_content_hash("put", "note.v0", "note:1", Some(&a)), "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d"), + (write_content_hash("put", "note.v0", "note:1", Some(&b)), "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f"), + (write_content_hash("put", "note.v0", "note:2", Some(&a)), "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8"), + (write_content_hash("delete", "note.v0", "note:1", None), "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37"), + ]; + for (hash, expected) in cases { + assert_eq!(hex(&hash.unwrap()), expected); + } +} + +#[test] +fn the_new_frames_encode_to_the_spec_bytes() { + let admitted = CultNetMessage::WriteAdmitted { + message_id: "put-1".into(), + operation: "put".into(), + schema_id: "note.v0".into(), + record_key: "note:1".into(), + }; + let bytes = encode_cultnet_message_to_vec(&admitted, CultNetWireContract::CultNetSchemaV0).unwrap(); + assert_eq!( + decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), + admitted + ); + // error.v1 with a nil id round-trips as nil, and the code enum is v0's. + let error = CultNetMessage::ErrorV1 { + error: "message_invalid: unreadable frame".into(), + code: Some(CultNetErrorCode::MessageInvalid), + details: None, + message_id: None, + }; + let bytes = encode_cultnet_message_to_vec(&error, CultNetWireContract::CultNetSchemaV0).unwrap(); + assert_eq!( + decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), + error + ); +} From 8bd31a5d4aa5ec14426466753a7926e913055fac Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:56:17 +0000 Subject: [PATCH 04/31] cultmesh-rs: receipt server follows Heimdall's review of #60 A refused put, a full dedupe table and a reused id refuse the write, not the session; only a protocol violation ends one. A frame that does not decode but holds a messageId is refused with that id. The server answers a receipt-capable hello with its own, listing both frames. The sink's authorization (new authorize_raw_document, default admit) runs before the session's remembered writes are consulted. A second hello does not change the capability: it is set once, by the first hello or the first write. A receipt that cannot be queued, and a write the payload budget would drop silently, end the session instead of leaving the write unanswered. Refusals to a receipt-capable session are queued reliably. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 229 ++++++++++++------ .../cultmesh-rs/tests/rudp_document_server.rs | 228 ++++++++++++++++- packages/cultnet-rs/src/contracts.rs | 14 ++ packages/cultnet-rs/tests/write_receipt.rs | 2 +- 4 files changed, 397 insertions(+), 76 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index b7d97d77..173ebf3b 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,7 +3,7 @@ use cultnet_rs::{ CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, send_error_code, write_content_hash, + encode_rudp_packet, is_permanent_send_error, readable_message_id, send_error_code, write_content_hash, }; use std::collections::{BTreeMap, HashMap}; #[cfg(test)] @@ -66,6 +66,14 @@ pub struct CultMeshRudpSnapshotQuery { /// Caller-owned admission/persistence port for received raw documents. pub trait CultMeshRudpRawDocumentSink { + /// Whether this put may be written, decided without writing it. A receipt-capable session's resend or + /// reused id is checked against it before the session's remembered writes are consulted, so a peer that + /// may not write is refused as such and never shown what the session remembers (write-receipt.md). + /// The default admits everything and leaves the decision to `accept_raw_document`. + fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { + Ok(()) + } + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; } @@ -459,6 +467,9 @@ struct SessionEntry { /// The peer's hello listed `cultnet.write_admitted.v0` and `cultnet.error.v1`: its writes are answered /// with a receipt or an `error.v1` naming the write's id (write-receipt.md). Framing only; it grants nothing. receipts: bool, + /// The capability is decided once, by the session's first hello or first write, whichever comes first; + /// a later hello changes nothing. + capability_settled: bool, /// Admitted writes of a receipt-capable session: message id -> content hash, so a resend of the same /// content is re-receipted without being applied again and the same id with other content is refused. admitted_writes: HashMap, @@ -466,6 +477,9 @@ struct SessionEntry { /// The most admitted writes a receipt-capable session remembers; a session at the limit refuses new writes /// (`PayloadBudgetFull`) rather than forget an id it promised to answer idempotently. +const WRITE_ADMITTED_VERSION: &str = "cultnet.write_admitted.v0"; +const ERROR_V1_VERSION: &str = "cultnet.error.v1"; + const MAX_REMEMBERED_WRITES_PER_SESSION: usize = 1024; /// A synchronous, multi-session CultNet RUDP raw-document server. @@ -617,6 +631,11 @@ where 0 }; if !self.payload_budget_allows(key, payload_charge) { + // A receipt-capable session's dropped write would go unanswered; ending the session answers it + // (the peer's writes settle as session_ended). Any other session is dropped from silently, as before. + if self.receipts(key) { + self.end_session(key, SessionEnd::Refused)?; + } return Ok(CultMeshRudpPollOutcome::Handled); } @@ -711,7 +730,7 @@ where Err(error) => Some(CultMeshRudpApplicationRejection { session: key, operation: CultMeshRudpApplicationOperation::Unreadable, - message_id: String::new(), + message_id: readable_message_id(&frame.payload).unwrap_or_default(), record_key: None, reason: CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")), }), @@ -723,19 +742,24 @@ where && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES) { let refusal = rejection.refusal_message(self.receipts(key)); - let packets = match self.sessions.remove(&key) { - Some(mut entry) => { - let packets = self.refusal_packets(&mut entry.session, &refusal, false); - self.sessions.insert(key, entry); - packets - } - None => Vec::new(), - }; - for packet in &packets { - if let Some(error) = self.send_packet(key.remote_addr, packet)? { - unsendable = Some(error); - break; - } + if let Some(error) = self.send_on_live_session(key, &refusal, self.receipts(key))? { + unsendable = Some(error); + } + kept_rejection = Some(rejection); + continue; + } + // A refused write of a receipt-capable session is answered with its error.v1 and the session + // goes on: only a protocol violation (the cases above and below) ends a session. + if matches!(rejection.operation, CultMeshRudpApplicationOperation::DocumentPutRaw) + && self.receipts(key) + && !matches!( + rejection.reason, + CultMeshRudpRejectionReason::ResponseSendFailed(_) + ) + { + let refusal = rejection.refusal_message(true); + if let Some(error) = self.send_on_live_session(key, &refusal, true)? { + unsendable = Some(error); } kept_rejection = Some(rejection); continue; @@ -928,6 +952,7 @@ where refused_deletes_in_a_row: 0, ending_deadline_monotonic_millis: None, receipts: false, + capability_settled: false, admitted_writes: HashMap::new(), }, ); @@ -964,29 +989,52 @@ where reason, })) }; - let content_hash = match write_content_hash( - "put", - &document.schema_id, - &document.record_key, - Some(&document.payload), - ) { - Ok(hash) => hash, - Err(error) => { - return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )); - } + // The capability is fixed by the first hello or, absent one, the first write. + if let Some(entry) = self.sessions.get_mut(&key) { + entry.capability_settled = true; + } + let receipts = self.receipts(key); + let receipt = CultMeshRudpRawDocumentReceipt { + session: key, + message_id: message_id.clone(), + transport_sequence, + received_at_unix_millis: now_unix, + document, }; - let remembered = self - .sessions - .get(&key) - .filter(|entry| entry.receipts) - .map(|entry| (entry.admitted_writes.get(&message_id).copied(), entry.admitted_writes.len())); - if let Some((prior, remembered_count)) = remembered { + let admitted = CultNetMessage::WriteAdmitted { + message_id: message_id.clone(), + operation: "put".into(), + schema_id: receipt.document.schema_id.clone(), + record_key: receipt.document.record_key.clone(), + }; + let mut content_hash = [0_u8; 32]; + if receipts { + // Authorization comes first: a peer that may not write learns nothing of what this + // session remembers, not even that an id was used. + if let Err(error) = self.sink.authorize_raw_document(&receipt) { + return reject(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); + } + content_hash = match write_content_hash( + "put", + &receipt.document.schema_id, + &receipt.document.record_key, + Some(&receipt.document.payload), + ) { + Ok(hash) => hash, + Err(error) => { + return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed( + format!("{error:#}"), + )); + } + }; + let (prior, remembered_count) = self + .sessions + .get(&key) + .map(|entry| (entry.admitted_writes.get(&message_id).copied(), entry.admitted_writes.len())) + .unwrap_or_default(); match prior { Some(prior) if prior == content_hash => { - self.send_write_admitted(key, now, &message_id, &document.schema_id, &document.record_key); - return Ok(None); + return self.receipt_sent(key, now, &admitted, reject); } Some(_) => return reject(CultMeshRudpRejectionReason::MessageIdReused), None if remembered_count >= MAX_REMEMBERED_WRITES_PER_SESSION => { @@ -995,14 +1043,13 @@ where None => {} } } - let (receipt_schema_id, receipt_record_key) = - (document.schema_id.clone(), document.record_key.clone()); + let document = &receipt.document; // Admit only what some snapshot request can return: the smallest // response that could carry this document is the record the snapshot // source would serve for it, alone, under the shortest message id // CultNet encodes, sized by the snapshot path's encoder and fragmented // as the snapshot path sends it. - let served = match self.snapshot_source.served_record(&document) { + let served = match self.snapshot_source.served_record(document) { Ok(served) => served, Err(error) => { return reject(CultMeshRudpRejectionReason::SnapshotSourceFailed( @@ -1032,22 +1079,17 @@ where max_fragment_count, }); } - let receipt = CultMeshRudpRawDocumentReceipt { - session: key, - message_id: message_id.clone(), - transport_sequence, - received_at_unix_millis: now_unix, - document, - }; if let Err(error) = self.sink.accept_raw_document(receipt) { return reject(CultMeshRudpRejectionReason::SinkRefused(format!( "{error:#}" ))); } // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. - if let Some(entry) = self.sessions.get_mut(&key).filter(|entry| entry.receipts) { - entry.admitted_writes.insert(message_id.clone(), content_hash); - self.send_write_admitted(key, now, &message_id, &receipt_schema_id, &receipt_record_key); + if receipts { + if let Some(entry) = self.sessions.get_mut(&key) { + entry.admitted_writes.insert(message_id.clone(), content_hash); + } + return self.receipt_sent(key, now, &admitted, reject); } } CultNetMessage::Hello { @@ -1060,8 +1102,30 @@ where .flatten() .any(|candidate| candidate == version) }; - if let Some(entry) = self.sessions.get_mut(&key) { - entry.receipts = listed("cultnet.write_admitted.v0") && listed("cultnet.error.v1"); + let Some(entry) = self.sessions.get_mut(&key).filter(|entry| !entry.capability_settled) else { + return Ok(None); + }; + entry.capability_settled = true; + entry.receipts = listed(WRITE_ADMITTED_VERSION) && listed(ERROR_V1_VERSION); + if entry.receipts { + // The server's own hello tells the client this is a receipt server, not a legacy one + // that has not answered yet. Best effort: the client's window settles a missing hello. + let hello = CultNetMessage::Hello { + runtime_id: "cultmesh-rs-rudp-document-server".into(), + runtime_kind: "cultmesh-rs".into(), + agent_id: None, + role: None, + display_name: None, + supported_document_types: None, + supported_mutation_contracts: None, + supported_message_versions: Some(vec![ + WRITE_ADMITTED_VERSION.into(), + ERROR_V1_VERSION.into(), + ]), + transport_profiles: None, + supports_schema_catalog: None, + }; + self.send_reliable_schema(key, now, &hello); } } CultNetMessage::SnapshotRequest { @@ -1203,29 +1267,34 @@ where Ok(None) } - /// Sends the receipt for an admitted put. Best effort: the write is already committed, so a receipt that - /// cannot be queued or sent is dropped and the peer's own window settles the write as unconfirmed. It is - /// never answered with a refusal, which would make an admitted write look refused. - fn send_write_admitted( + /// Sends the receipt for an admitted put. A receipt that cannot be queued or sent would leave a committed + /// write unanswered, and a refusal would make it look refused; so the session ends instead, and the peer's + /// write settles as unconfirmed (`session_ended`). The write stays remembered. + fn receipt_sent( &mut self, key: CultMeshRudpSessionKey, now: u64, - message_id: &str, - schema_id: &str, - record_key: &str, - ) { - let receipt = CultNetMessage::WriteAdmitted { - message_id: message_id.into(), - operation: "put".into(), - schema_id: schema_id.into(), - record_key: record_key.into(), - }; - let Ok(payload) = encode_cultnet_message_to_vec(&receipt, CultNetWireContract::CultNetSchemaV0) else { - return; + receipt: &CultNetMessage, + reject: impl Fn(CultMeshRudpRejectionReason) -> Result>, + ) -> Result> { + if self.send_reliable_schema(key, now, receipt) { + return Ok(None); + } + self.end_session(key, SessionEnd::Refused)?; + reject(CultMeshRudpRejectionReason::ResponseSendFailed( + "the write receipt could not be sent".into(), + )) + } + + /// Queues `message` reliably and in order on the schema channel and sends it; false when it could not be + /// encoded, queued or sent. + fn send_reliable_schema(&mut self, key: CultMeshRudpSessionKey, now: u64, message: &CultNetMessage) -> bool { + let Ok(payload) = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0) else { + return false; }; let max_fragment_bytes = self.options.max_fragment_bytes; let Some(entry) = self.sessions.get_mut(&key) else { - return; + return false; }; let Ok(packets) = entry.session.send_many( "schema", @@ -1239,13 +1308,31 @@ where }, Some(max_fragment_bytes), ) else { - return; + return false; }; + packets + .iter() + .all(|packet| matches!(self.send_packet(key.remote_addr, packet), Ok(None))) + } + + /// Answers `refusal` on the session while it stays live; `Some` is a permanent send failure. + fn send_on_live_session( + &mut self, + key: CultMeshRudpSessionKey, + refusal: &CultNetMessage, + reliable: bool, + ) -> Result> { + let Some(mut entry) = self.sessions.remove(&key) else { + return Ok(None); + }; + let packets = self.refusal_packets(&mut entry.session, refusal, reliable); + self.sessions.insert(key, entry); for packet in &packets { - if !matches!(self.send_packet(key.remote_addr, packet), Ok(None)) { - break; + if let Some(error) = self.send_packet(key.remote_addr, packet)? { + return Ok(Some(error)); } } + Ok(None) } /// Ends a session before its peer is done with it, and says goodbye. It is @@ -1288,11 +1375,11 @@ where Ok(()) } - /// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response. fn receipts(&self, key: CultMeshRudpSessionKey) -> bool { self.sessions.get(&key).is_some_and(|entry| entry.receipts) } + /// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response. fn refusal_packets( &self, session: &mut CultNetRudpSession, diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 7eb6fcdc..c05eccf4 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -22,6 +22,8 @@ use std::time::Duration; struct SinkState { receipts: Vec, failures_remaining: usize, + /// Record keys the sink refuses to authorize, before and at the write. + denied_keys: Vec, } #[derive(Clone, Default)] @@ -34,11 +36,28 @@ impl Sink { ..Default::default() }))) } + + fn denying(keys: &[&str]) -> Self { + Self(Arc::new(Mutex::new(SinkState { + denied_keys: keys.iter().map(|key| key.to_string()).collect(), + ..Default::default() + }))) + } } impl CultMeshRudpRawDocumentSink for Sink { + fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { + if self.0.lock().unwrap().denied_keys.contains(&receipt.document.record_key) { + anyhow::bail!("not authorized"); + } + Ok(()) + } + fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { let mut state = self.0.lock().unwrap(); + if state.denied_keys.contains(&receipt.document.record_key) { + anyhow::bail!("not authorized"); + } if state.failures_remaining > 0 { state.failures_remaining -= 1; anyhow::bail!("injected sink failure CANARY-7f3a"); @@ -2112,6 +2131,36 @@ fn frames_after( Ok(frames) } +/// The hello a receipt server sends a receipt-capable client, ahead of any answer. +fn server_hello() -> CultNetMessage { + CultNetMessage::Hello { + runtime_id: "cultmesh-rs-rudp-document-server".into(), + runtime_kind: "cultmesh-rs".into(), + agent_id: None, + role: None, + display_name: None, + supported_document_types: None, + supported_mutation_contracts: None, + supported_message_versions: Some(vec![ + "cultnet.write_admitted.v0".into(), + "cultnet.error.v1".into(), + ]), + transport_profiles: None, + supports_schema_catalog: None, + } +} + +/// The `count` answers after the server's hello, which must come first. +fn answers_after( + server: &mut CultMeshRudpDocumentServer, + client: &mut CultNetRudpSocketTransportConnection, + count: usize, +) -> Result> { + let mut frames = frames_after(server, client, count + 1)?; + assert_eq!(frames.first(), Some(&server_hello()), "the server answers the hello first"); + Ok(frames.split_off(1)) +} + fn admitted(id: &str, key: &str) -> CultNetMessage { CultNetMessage::WriteAdmitted { message_id: id.into(), @@ -2129,7 +2178,7 @@ fn an_admitted_put_on_a_receipt_connection_is_answered_with_a_receipt_carrying_i connect(&mut server, &mut [&mut peer])?; send_reliable(&mut peer, &receipt_hello())?; send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); Ok(()) } @@ -2156,7 +2205,7 @@ fn a_resend_of_an_admitted_write_is_re_receipted_without_applying_it_twice() -> send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; assert_eq!( - frames_after(&mut server, &mut peer, 2)?, + answers_after(&mut server, &mut peer, 2)?, vec![admitted("m1", "note:1"), admitted("m1", "note:1")] ); assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); @@ -2172,7 +2221,7 @@ fn the_same_id_with_other_content_is_refused_message_invalid_never_re_receipted( send_reliable(&mut peer, &receipt_hello())?; send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; send_reliable(&mut peer, &put("m1", "note:1", vec![9, 9, 9]))?; - let frames = frames_after(&mut server, &mut peer, 2)?; + let frames = answers_after(&mut server, &mut peer, 2)?; assert_eq!(frames[0], admitted("m1", "note:1")); let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &frames[1] else { panic!("the second answer must be a refusal, got {:?}", frames[1]); @@ -2192,7 +2241,7 @@ fn a_refused_put_on_a_receipt_connection_is_a_typed_error_naming_its_id() -> Res connect(&mut server, &mut [&mut peer])?; send_reliable(&mut peer, &receipt_hello())?; send_reliable(&mut peer, &put("m7", "note:7", vec![1]))?; - let frames = frames_after(&mut server, &mut peer, 1)?; + let frames = answers_after(&mut server, &mut peer, 1)?; let Some(CultNetMessage::ErrorV1 { code, message_id, .. }) = frames.first() else { panic!("expected error.v1, got {frames:?}"); }; @@ -2218,3 +2267,174 @@ fn a_hello_listing_only_one_of_the_two_frames_does_not_opt_in() -> Result<()> { } Ok(()) } + +fn refusal_of(message: &CultNetMessage) -> (Option, Option) { + let CultNetMessage::ErrorV1 { code, message_id, .. } = message else { + panic!("expected error.v1, got {message:?}"); + }; + (code.clone(), message_id.clone()) +} + +#[test] +fn a_refused_put_leaves_the_receipt_session_open_for_the_next_write() -> Result<()> { + let sink = Sink::fail_once(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 207)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &put("m2", "note:2", vec![2]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); + assert_eq!(answers[1], admitted("m2", "note:2")); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +#[test] +fn a_reused_id_refuses_the_write_not_the_session() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 208)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &put("m1", "note:1", vec![2]))?; + send_reliable(&mut peer, &put("m3", "note:3", vec![3]))?; + let answers = answers_after(&mut server, &mut peer, 3)?; + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); + assert_eq!(answers[2], admitted("m3", "note:3")); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +#[test] +fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 209)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for index in 0..1024 { + send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; + if index % 16 == 15 { + for _ in 0..40 { + server.poll_once()?; + peer.poll_resends()?; + while peer.receive_once()?.is_some() {} + } + } + } + send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; + send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; + let answers = frames_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (None, Some("over".into()))); + assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +#[test] +fn a_readable_id_with_a_bad_field_is_refused_with_that_id() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 210)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + // A put with no document: it does not decode, but its id is there to read. + let frame = rmp_serde::to_vec_named(&serde_json::json!({ + "schemaVersion": "cultnet.document_put_raw.v0", + "messageId": "bad-1", + }))?; + peer.send_reliable("schema", frame)?; + peer.send_reliable("schema", garbage())?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into()))); + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), None), "no id can be read"); + Ok(()) +} + +#[test] +fn authorization_is_decided_before_a_reused_id_is_looked_at() -> Result<()> { + let sink = Sink::denying(&["secret"]); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 211)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &put("m1", "secret", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!( + refusal_of(&answers[1]), + (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into())), + "a peer that may not write the key is not told the id was used" + ); + Ok(()) +} + +#[test] +fn a_second_hello_does_not_change_the_capability() -> Result<()> { + let mut downgrade = receipt_hello(); + if let CultNetMessage::Hello { supported_message_versions, .. } = &mut downgrade { + *supported_message_versions = None; + } + // Capable, then a hello listing nothing: still capable. + let sink = Sink::default(); + let mut server_a = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server_a.local_addr()?, 212)?; + connect(&mut server_a, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &downgrade)?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(answers_after(&mut server_a, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + // Legacy by its first write, then a capable hello: still legacy. + let mut server_b = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; + let mut late = client(server_b.local_addr()?, 213)?; + connect(&mut server_b, &mut [&mut late])?; + send_reliable(&mut late, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut late, &receipt_hello())?; + send_reliable(&mut late, &put("m2", "note:2", vec![1]))?; + assert_eq!(frames_after(&mut server_b, &mut late, 1)?, vec![]); + Ok(()) +} + +#[test] +fn a_receipt_session_that_runs_out_of_payload_budget_is_ended_not_left_silent() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_admitted_payload_bytes_per_session: 400, + max_snapshot_response_bytes: 200, + ..Default::default() + }; + let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 214)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for index in 0..20 { + send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1; 64]))?; + } + frames_after(&mut server, &mut peer, usize::MAX)?; + assert_eq!(server.session_count(), 0, "the writes the budget dropped were answered by ending the session"); + Ok(()) +} + +#[test] +fn a_receipt_that_cannot_be_queued_ends_the_session_instead_of_leaving_the_write_unanswered() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_pending_reliable_packets_per_session: 3, + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 215)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + // The peer never reads, so it acknowledges nothing and the server's reliable queue fills. + for index in 0..8 { + send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; + for _ in 0..20 { + server.poll_once()?; + } + } + assert_eq!(server.session_count(), 0, "a receipt the queue could not take ended the session"); + Ok(()) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index cc9a9c34..28ce7663 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -1979,3 +1979,17 @@ pub fn write_content_hash( rmpv::encode::write_value(&mut bytes, &value)?; Ok(Sha256::digest(&bytes).into()) } + +/// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal +/// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not +/// a MessagePack map or hold no non-empty string `messageId`. +pub fn readable_message_id(bytes: &[u8]) -> Option { + let value = rmpv::decode::read_value(&mut &*bytes).ok()?; + let rmpv::Value::Map(entries) = value else { + return None; + }; + entries.into_iter().find_map(|(key, value)| match (key.as_str(), value) { + (Some("messageId"), rmpv::Value::String(id)) => id.into_str().filter(|id| !id.is_empty()), + _ => None, + }) +} diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs index 686c3bbe..259f2909 100644 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -25,7 +25,7 @@ fn the_content_hash_matches_the_spec_vectors() { } #[test] -fn the_new_frames_encode_to_the_spec_bytes() { +fn the_new_frames_decode_back_equal() { let admitted = CultNetMessage::WriteAdmitted { message_id: "put-1".into(), operation: "put".into(), From 7a560b1984032c07268a469385b827674170d2fa Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:58:39 +0000 Subject: [PATCH 05/31] spec: dedupe scope is at least the session; a resend after a reconnect is a new write Per Heimdall's review. The dedupe key lives for the life of its session, bounded per session without forgetting an id the server promised to answer. A longer retention is optional and never to be relied on. After a reconnect callers resend only writes that are idempotent by record key and read back before resending any other. Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 86f9f230..90acb8bb 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -28,11 +28,11 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir 2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission. 3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read. 4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer. -5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). It is retained at least `DEDUPE_RETENTION_MS` (10 minutes) and the life of the session. +5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (a session at the bound refuses new writes with an `error.v1` carrying the new write's id, and keeps answering the ids it remembers; it never forgets one it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.** - Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice. - Same key, same content hash, answer was a refusal: the same refusal again. - **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged. - - Past retention a resend is a new write. + - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. 6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. 7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. @@ -42,11 +42,11 @@ A write settles as exactly one of: - **admitted**: a `write_admitted` whose `messageId` is the write's. - **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. -- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content is safe against a receipt-capable server (rule 5); against a legacy server a resend may apply the write twice, so the caller reads the record back first. +- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. **Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window. -**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. After a resend with the same id and content, rule 5 makes a duplicate harmless on a receipt-capable server. +**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above. **Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing. From d4f440ac7c058db7f5b0a13d7578f8cbf9118521 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:22:50 +0000 Subject: [PATCH 06/31] cultmesh-rs: receipt server looks the id up first, remembers refusals, answers deletes and unqueueable refusals Follows Heimdall's second review of #60: replay by (session,id) before any authorization; refusals stored with their hash; delete shares the dedupe table and settles capability; a refusal the queue cannot take ends the session; a peer's refusal frames and a receipt session's refused deletes are not violations; the write limit has its own fixed text; the sink trait says Ok means committed and visible. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 362 ++++++++++++------ .../cultmesh-rs/tests/rudp_document_server.rs | 174 ++++++++- packages/cultnet-rs/tests/write_receipt.rs | 39 ++ 3 files changed, 430 insertions(+), 145 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 173ebf3b..1b90932b 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -10,6 +10,7 @@ use std::collections::{BTreeMap, HashMap}; use std::collections::BTreeSet; use std::io::ErrorKind; use std::net::{SocketAddr, UdpSocket}; +use std::ops::ControlFlow; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_UDP_DATAGRAM_BYTES: usize = 65_535; @@ -66,14 +67,16 @@ pub struct CultMeshRudpSnapshotQuery { /// Caller-owned admission/persistence port for received raw documents. pub trait CultMeshRudpRawDocumentSink { - /// Whether this put may be written, decided without writing it. A receipt-capable session's resend or - /// reused id is checked against it before the session's remembered writes are consulted, so a peer that - /// may not write is refused as such and never shown what the session remembers (write-receipt.md). - /// The default admits everything and leaves the decision to `accept_raw_document`. + /// Whether this put may be written, decided without writing it. A receipt-capable session asks it only + /// the first time it sees a message id; a resend is answered from what the session remembers + /// (write-receipt.md, rule 5). The default admits everything and leaves the decision to + /// `accept_raw_document`. fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { Ok(()) } + /// Commits the document. `Ok` means committed and visible to readers: a receipt-capable peer is told + /// `write_admitted` on the strength of it, so a sink that has only queued the write must not return `Ok`. fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; } @@ -269,6 +272,9 @@ pub enum CultMeshRudpRejectionReason { /// A receipt-capable session sent a message id it already used for a write with other content /// (write-receipt.md, rule 5). Nothing reached the sink; the first write's state is unchanged. MessageIdReused, + /// A receipt-capable session already remembers `MAX_REMEMBERED_WRITES_PER_SESSION` writes and takes no new + /// id. Nothing reached the sink. + WriteLimitReached, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -278,6 +284,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::DeleteNotSupported => f.write_str("the server does not take deletes"), Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"), Self::MessageIdReused => f.write_str("the message id was already used for different content"), + Self::WriteLimitReached => f.write_str("the session remembers as many writes as it will"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -318,7 +325,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 12] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 13] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -331,6 +338,7 @@ pub(crate) const REFUSAL_TEXTS: [&str; 12] = [ "the server does not take deletes", "the message could not be read", "the message id was already used for different content", + "the session's remembered-write limit is full", ]; impl CultMeshRudpRejectionReason { @@ -349,6 +357,7 @@ impl CultMeshRudpRejectionReason { Self::DeleteNotSupported => 9, Self::MessageUnreadable(_) => 10, Self::MessageIdReused => 11, + Self::WriteLimitReached => 12, }] } } @@ -444,6 +453,15 @@ pub enum CultMeshRudpPollOutcome { ApplicationRejected(CultMeshRudpApplicationRejection), } +/// How a refusal sent on a live session fared. +enum Answered { + Sent, + /// The refusal can never be sent as built (a permanent send failure). + Unsendable(std::io::Error), + /// The session could not encode or queue the refusal: it was not sent at all. + Unqueueable, +} + /// Why a session ends before its peer is done with it. enum SessionEnd { /// The session refused a packet. @@ -454,6 +472,22 @@ enum SessionEnd { Unsendable(std::io::Error), } +fn write_rejection( + session: CultMeshRudpSessionKey, + operation: CultMeshRudpApplicationOperation, + message_id: &str, + record_key: &str, + reason: CultMeshRudpRejectionReason, +) -> CultMeshRudpApplicationRejection { + CultMeshRudpApplicationRejection { + session, + operation, + message_id: message_id.to_owned(), + record_key: Some(record_key.to_owned()), + reason, + } +} + struct SessionEntry { session: CultNetRudpSession, created_at_monotonic_millis: u64, @@ -470,13 +504,14 @@ struct SessionEntry { /// The capability is decided once, by the session's first hello or first write, whichever comes first; /// a later hello changes nothing. capability_settled: bool, - /// Admitted writes of a receipt-capable session: message id -> content hash, so a resend of the same - /// content is re-receipted without being applied again and the same id with other content is refused. - admitted_writes: HashMap, + /// Answered writes of a receipt-capable session: message id -> (content hash, the answer given), refusals + /// included, so a resend of the same content is answered again without being decided again and the same + /// id with other content is refused. + answered_writes: HashMap, } -/// The most admitted writes a receipt-capable session remembers; a session at the limit refuses new writes -/// (`PayloadBudgetFull`) rather than forget an id it promised to answer idempotently. +/// The most answered writes a receipt-capable session remembers; a session at the limit refuses new writes +/// (`WriteLimitReached`) rather than forget an id it promised to answer idempotently. const WRITE_ADMITTED_VERSION: &str = "cultnet.write_admitted.v0"; const ERROR_V1_VERSION: &str = "cultnet.error.v1"; @@ -698,9 +733,12 @@ where ); // Runs of refusals this frame extends or breaks. A session is kept through a short run and // ended by `MAX_CONSECUTIVE_REFUSED_FRAMES` in a row. + // Only an undecodable frame is a protocol violation of a receipt-capable session: its refused + // deletes are refused writes, answered one by one like its refused puts. + let receipt_session = self.receipts(key); let (is_unreadable, is_delete) = ( decoded.is_err(), - matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })), + !receipt_session && matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })), ); let run = self.sessions.get_mut(&key).map(|entry| { entry.undecodable_frames_in_a_row = if is_unreadable { @@ -742,24 +780,38 @@ where && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES) { let refusal = rejection.refusal_message(self.receipts(key)); - if let Some(error) = self.send_on_live_session(key, &refusal, self.receipts(key))? { - unsendable = Some(error); + match self.send_on_live_session(key, &refusal, self.receipts(key))? { + Answered::Sent => {} + Answered::Unsendable(error) => unsendable = Some(error), + Answered::Unqueueable => { + self.end_session(key, SessionEnd::Refused)?; + return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); + } } kept_rejection = Some(rejection); continue; } // A refused write of a receipt-capable session is answered with its error.v1 and the session // goes on: only a protocol violation (the cases above and below) ends a session. - if matches!(rejection.operation, CultMeshRudpApplicationOperation::DocumentPutRaw) - && self.receipts(key) + if matches!( + rejection.operation, + CultMeshRudpApplicationOperation::DocumentPutRaw | CultMeshRudpApplicationOperation::DocumentDelete + ) && self.receipts(key) && !matches!( rejection.reason, CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { let refusal = rejection.refusal_message(true); - if let Some(error) = self.send_on_live_session(key, &refusal, true)? { - unsendable = Some(error); + match self.send_on_live_session(key, &refusal, true)? { + Answered::Sent => {} + Answered::Unsendable(error) => unsendable = Some(error), + // An unanswerable write is answered by ending the session: its peer settles it + // as `session_ended`, never as silence. + Answered::Unqueueable => { + self.end_session(key, SessionEnd::Refused)?; + return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); + } } kept_rejection = Some(rejection); continue; @@ -953,7 +1005,7 @@ where ending_deadline_monotonic_millis: None, receipts: false, capability_settled: false, - admitted_writes: HashMap::new(), + answered_writes: HashMap::new(), }, ); accept @@ -979,21 +1031,21 @@ where message_id, document, } => { - let record_key = document.record_key.clone(); - let reject = |reason| { - Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::DocumentPutRaw, - message_id: message_id.clone(), - record_key: Some(record_key.clone()), - reason, - })) - }; // The capability is fixed by the first hello or, absent one, the first write. if let Some(entry) = self.sessions.get_mut(&key) { entry.capability_settled = true; } let receipts = self.receipts(key); + let record_key = document.record_key.clone(); + let rejection = |reason| { + write_rejection( + key, + CultMeshRudpApplicationOperation::DocumentPutRaw, + &message_id, + &record_key, + reason, + ) + }; let receipt = CultMeshRudpRawDocumentReceipt { session: key, message_id: message_id.clone(), @@ -1001,95 +1053,46 @@ where received_at_unix_millis: now_unix, document, }; + if !receipts { + return Ok(self.admit_put(receipt, false).err().map(rejection)); + } let admitted = CultNetMessage::WriteAdmitted { message_id: message_id.clone(), operation: "put".into(), schema_id: receipt.document.schema_id.clone(), record_key: receipt.document.record_key.clone(), }; - let mut content_hash = [0_u8; 32]; - if receipts { - // Authorization comes first: a peer that may not write learns nothing of what this - // session remembers, not even that an id was used. - if let Err(error) = self.sink.authorize_raw_document(&receipt) { - return reject(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); - } - content_hash = match write_content_hash( - "put", - &receipt.document.schema_id, - &receipt.document.record_key, - Some(&receipt.document.payload), - ) { - Ok(hash) => hash, - Err(error) => { - return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )); - } - }; - let (prior, remembered_count) = self - .sessions - .get(&key) - .map(|entry| (entry.admitted_writes.get(&message_id).copied(), entry.admitted_writes.len())) - .unwrap_or_default(); - match prior { - Some(prior) if prior == content_hash => { - return self.receipt_sent(key, now, &admitted, reject); - } - Some(_) => return reject(CultMeshRudpRejectionReason::MessageIdReused), - None if remembered_count >= MAX_REMEMBERED_WRITES_PER_SESSION => { - return reject(CultMeshRudpRejectionReason::PayloadBudgetFull); - } - None => {} - } - } - let document = &receipt.document; - // Admit only what some snapshot request can return: the smallest - // response that could carry this document is the record the snapshot - // source would serve for it, alone, under the shortest message id - // CultNet encodes, sized by the snapshot path's encoder and fragmented - // as the snapshot path sends it. - let served = match self.snapshot_source.served_record(document) { - Ok(served) => served, + let content_hash = match write_content_hash( + "put", + &receipt.document.schema_id, + &receipt.document.record_key, + Some(&receipt.document.payload), + ) { + Ok(hash) => hash, Err(error) => { - return reject(CultMeshRudpRejectionReason::SnapshotSourceFailed( + return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( format!("{error:#}"), - )); + )))); } }; - let alone = CultNetMessage::SnapshotResponseRaw { - message_id: SHORTEST_MESSAGE_ID.into(), - documents: vec![served], - }; - let response_bytes = match encode_snapshot_response(&alone) { - Ok(payload) => payload.len(), - Err(error) => { - return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed(error)); - } - }; - let fragment_count = self.fragments_for(response_bytes); - let max_fragment_count = self.max_fragments_per_response(); - if response_bytes > self.options.max_snapshot_response_bytes - || fragment_count > max_fragment_count + // The id is looked up first: a resend is answered with what the session already said, and + // the sink is asked only about an id the session has not seen. + if let ControlFlow::Break(outcome) = + self.seen_write(key, now, &message_id, content_hash, &rejection)? { - return reject(CultMeshRudpRejectionReason::DocumentUnservable { - response_bytes, - max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, - fragment_count, - max_fragment_count, - }); + return Ok(outcome); } - if let Err(error) = self.sink.accept_raw_document(receipt) { - return reject(CultMeshRudpRejectionReason::SinkRefused(format!( - "{error:#}" - ))); - } - // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. - if receipts { - if let Some(entry) = self.sessions.get_mut(&key) { - entry.admitted_writes.insert(message_id.clone(), content_hash); + match self.admit_put(receipt, true) { + Ok(()) => { + // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. + self.remember(key, &message_id, content_hash, admitted.clone()); + return self.answer_sent(key, now, &admitted, &rejection); + } + Err(reason) => { + let refused = rejection(reason); + self.remember(key, &message_id, content_hash, refused.refusal_message(true)); + return Ok(Some(refused)); } - return self.receipt_sent(key, now, &admitted, reject); } } CultNetMessage::Hello { @@ -1251,39 +1254,141 @@ where } CultNetMessage::DocumentDelete { message_id, + schema_id, record_key, - .. } => { - return Ok(Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::DocumentDelete, - message_id, - record_key: Some(record_key), - reason: CultMeshRudpRejectionReason::DeleteNotSupported, - })); + // A delete settles the connection's capability exactly as a put does. + if let Some(entry) = self.sessions.get_mut(&key) { + entry.capability_settled = true; + } + let rejection = |reason| { + write_rejection( + key, + CultMeshRudpApplicationOperation::DocumentDelete, + &message_id, + &record_key, + reason, + ) + }; + if !self.receipts(key) { + return Ok(Some(rejection(CultMeshRudpRejectionReason::DeleteNotSupported))); + } + let content_hash = match write_content_hash("delete", &schema_id, &record_key, None) { + Ok(hash) => hash, + Err(error) => { + return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( + format!("{error:#}"), + )))); + } + }; + if let ControlFlow::Break(outcome) = + self.seen_write(key, now, &message_id, content_hash, &rejection)? + { + return Ok(outcome); + } + let refused = rejection(CultMeshRudpRejectionReason::DeleteNotSupported); + self.remember(key, &message_id, content_hash, refused.refusal_message(true)); + return Ok(Some(refused)); } _ => {} } Ok(None) } - /// Sends the receipt for an admitted put. A receipt that cannot be queued or sent would leave a committed - /// write unanswered, and a refusal would make it look refused; so the session ends instead, and the peer's - /// write settles as unconfirmed (`session_ended`). The write stays remembered. - fn receipt_sent( + /// What a receipt-capable session already knows of `message_id`. `Continue` is a first sighting that + /// the session has room to remember. A repeat with the same content is answered with the stored answer, + /// a repeat with other content (a put or a delete of any record) is `MessageIdReused`, and a session + /// that remembers its limit takes no new id. + fn seen_write( &mut self, key: CultMeshRudpSessionKey, now: u64, - receipt: &CultNetMessage, - reject: impl Fn(CultMeshRudpRejectionReason) -> Result>, + message_id: &str, + content_hash: [u8; 32], + rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, + ) -> Result>> { + let (prior, remembered) = self + .sessions + .get(&key) + .map(|entry| (entry.answered_writes.get(message_id).cloned(), entry.answered_writes.len())) + .unwrap_or_default(); + Ok(ControlFlow::Break(match prior { + Some((hash, answer)) if hash == content_hash => self.answer_sent(key, now, &answer, rejection)?, + Some(_) => Some(rejection(CultMeshRudpRejectionReason::MessageIdReused)), + None if remembered >= MAX_REMEMBERED_WRITES_PER_SESSION => { + Some(rejection(CultMeshRudpRejectionReason::WriteLimitReached)) + } + None => return Ok(ControlFlow::Continue(())), + })) + } + + fn remember(&mut self, key: CultMeshRudpSessionKey, message_id: &str, content_hash: [u8; 32], answer: CultNetMessage) { + if let Some(entry) = self.sessions.get_mut(&key) { + entry.answered_writes.insert(message_id.to_owned(), (content_hash, answer)); + } + } + + /// Sends the answer owed a write. An answer that cannot be queued or sent would leave the write + /// unanswered, and a different answer would contradict what the sink did; so the session ends instead, + /// and the peer's write settles as unconfirmed (`session_ended`). The answer stays remembered. + fn answer_sent( + &mut self, + key: CultMeshRudpSessionKey, + now: u64, + answer: &CultNetMessage, + rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, ) -> Result> { - if self.send_reliable_schema(key, now, receipt) { + if self.send_reliable_schema(key, now, answer) { return Ok(None); } self.end_session(key, SessionEnd::Refused)?; - reject(CultMeshRudpRejectionReason::ResponseSendFailed( - "the write receipt could not be sent".into(), - )) + Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseSendFailed( + "the write answer could not be sent".into(), + )))) + } + + /// Whether a put may be committed, committing it. `authorize` asks the sink first, as a receipt-capable + /// session does on an id's first sighting. + fn admit_put( + &mut self, + receipt: CultMeshRudpRawDocumentReceipt, + authorize: bool, + ) -> std::result::Result<(), CultMeshRudpRejectionReason> { + if authorize { + if let Err(error) = self.sink.authorize_raw_document(&receipt) { + return Err(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); + } + } + let document = &receipt.document; + // Admit only what some snapshot request can return: the smallest + // response that could carry this document is the record the snapshot + // source would serve for it, alone, under the shortest message id + // CultNet encodes, sized by the snapshot path's encoder and fragmented + // as the snapshot path sends it. + let served = self + .snapshot_source + .served_record(document) + .map_err(|error| CultMeshRudpRejectionReason::SnapshotSourceFailed(format!("{error:#}")))?; + let alone = CultNetMessage::SnapshotResponseRaw { + message_id: SHORTEST_MESSAGE_ID.into(), + documents: vec![served], + }; + let response_bytes = encode_snapshot_response(&alone) + .map_err(CultMeshRudpRejectionReason::ResponseEncodingFailed)? + .len(); + let fragment_count = self.fragments_for(response_bytes); + let max_fragment_count = self.max_fragments_per_response(); + if response_bytes > self.options.max_snapshot_response_bytes || fragment_count > max_fragment_count { + return Err(CultMeshRudpRejectionReason::DocumentUnservable { + response_bytes, + max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, + fragment_count, + max_fragment_count, + }); + } + self.sink + .accept_raw_document(receipt) + .map_err(|error| CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))) } /// Queues `message` reliably and in order on the schema channel and sends it; false when it could not be @@ -1315,24 +1420,27 @@ where .all(|packet| matches!(self.send_packet(key.remote_addr, packet), Ok(None))) } - /// Answers `refusal` on the session while it stays live; `Some` is a permanent send failure. + /// Answers `refusal` on the session while it stays live. fn send_on_live_session( &mut self, key: CultMeshRudpSessionKey, refusal: &CultNetMessage, reliable: bool, - ) -> Result> { + ) -> Result { let Some(mut entry) = self.sessions.remove(&key) else { - return Ok(None); + return Ok(Answered::Sent); }; let packets = self.refusal_packets(&mut entry.session, refusal, reliable); self.sessions.insert(key, entry); + if packets.is_empty() { + return Ok(Answered::Unqueueable); + } for packet in &packets { if let Some(error) = self.send_packet(key.remote_addr, packet)? { - return Ok(Some(error)); + return Ok(Answered::Unsendable(error)); } } - Ok(None) + Ok(Answered::Sent) } /// Ends a session before its peer is done with it, and says goodbye. It is diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index c05eccf4..645fb5df 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2329,6 +2329,8 @@ fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; let answers = frames_after(&mut server, &mut peer, 2)?; assert_eq!(refusal_of(&answers[0]), (None, Some("over".into()))); + let CultNetMessage::ErrorV1 { error, .. } = &answers[0] else { unreachable!() }; + assert_eq!(error, "the session's remembered-write limit is full", "the cap has its own fixed text"); assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); assert_eq!(server.session_count(), 1); Ok(()) @@ -2354,24 +2356,6 @@ fn a_readable_id_with_a_bad_field_is_refused_with_that_id() -> Result<()> { Ok(()) } -#[test] -fn authorization_is_decided_before_a_reused_id_is_looked_at() -> Result<()> { - let sink = Sink::denying(&["secret"]); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 211)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "secret", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!( - refusal_of(&answers[1]), - (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into())), - "a peer that may not write the key is not told the id was used" - ); - Ok(()) -} - #[test] fn a_second_hello_does_not_change_the_capability() -> Result<()> { let mut downgrade = receipt_hello(); @@ -2438,3 +2422,157 @@ fn a_receipt_that_cannot_be_queued_ends_the_session_instead_of_leaving_the_write assert_eq!(server.session_count(), 0, "a receipt the queue could not take ended the session"); Ok(()) } + +fn delete(id: &str, key: &str) -> CultNetMessage { + CultNetMessage::DocumentDelete { + message_id: id.into(), + schema_id: "test.raw.v1".into(), + record_key: key.into(), + } +} + +#[test] +fn a_resend_is_answered_from_memory_and_the_sink_is_not_asked_again() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 216)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + // The peer's authority over the key ends; the answer already given is still the answer. + sink.0.lock().unwrap().denied_keys.push("note:1".into()); + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + Ok(()) +} + +#[test] +fn a_used_id_with_other_content_is_message_invalid_even_for_a_key_the_peer_may_not_write() -> Result<()> { + let sink = Sink::denying(&["secret"]); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 211)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &put("m1", "secret", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); + Ok(()) +} + +#[test] +fn a_refused_write_is_remembered_and_its_resend_replays_the_refusal() -> Result<()> { + let sink = Sink::fail_once(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 217)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); + assert_eq!(answers[1], answers[0], "the same refusal again, not a fresh attempt that would now succeed"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); + Ok(()) +} + +#[test] +fn a_delete_that_reuses_a_puts_id_is_message_invalid() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 218)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + send_reliable(&mut peer, &delete("m1", "note:1"))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(answers[0], admitted("m1", "note:1")); + let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &answers[1] else { + panic!("expected error.v1, got {:?}", answers[1]); + }; + assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); + assert_eq!(message_id.as_deref(), Some("m1")); + assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); + Ok(()) +} + +#[test] +fn deletes_on_a_receipt_session_are_refused_one_by_one_and_never_end_it() -> Result<()> { + let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 219)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for index in 0..12 { + send_reliable(&mut peer, &delete(&format!("d{index}"), "note:1"))?; + } + send_reliable(&mut peer, &delete("d3", "note:1"))?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 14)?; + assert_eq!(answers.len(), 14); + assert_eq!(refusal_of(&answers[12]), refusal_of(&answers[3]), "a resent delete replays its refusal"); + assert_eq!(answers[13], admitted("m1", "note:1")); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +#[test] +fn a_delete_as_the_first_frame_settles_the_connection_as_legacy() -> Result<()> { + let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 220)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &delete("d1", "note:1"))?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + let frames = frames_after(&mut server, &mut peer, 3)?; + assert_eq!(frames.len(), 1, "only the legacy delete refusal: {frames:?}"); + assert!(matches!(frames[0], CultNetMessage::Error { .. }), "{:?}", frames[0]); + Ok(()) +} + +#[test] +fn a_refusal_that_cannot_be_queued_ends_the_session_instead_of_vanishing() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_pending_reliable_packets_per_session: 3, + ..Default::default() + }; + let mut server = server(options, Clock::new(70_000), Sink::denying(&["x"]), Source::default())?; + let mut peer = client(server.local_addr()?, 221)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for index in 0..8 { + send_reliable(&mut peer, &put(&format!("w{index}"), "x", vec![1]))?; + for _ in 0..20 { + server.poll_once()?; + } + } + assert_eq!(server.session_count(), 0, "a refusal the queue could not take ended the session"); + Ok(()) +} + +#[test] +fn refusal_frames_from_a_peer_are_not_protocol_violations() -> Result<()> { + let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 222)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for _ in 0..12 { + send_reliable( + &mut peer, + &CultNetMessage::ErrorV1 { + error: "message_invalid: nope".into(), + code: Some(CultNetErrorCode::MessageInvalid), + details: None, + message_id: Some("x".into()), + }, + )?; + send_reliable( + &mut peer, + &CultNetMessage::Error { error: "nope".into(), code: None, details: None }, + )?; + } + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + assert_eq!(server.session_count(), 1); + Ok(()) +} diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs index 259f2909..20b3739a 100644 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -50,3 +50,42 @@ fn the_new_frames_decode_back_equal() { error ); } + +fn unhex(text: &str) -> Vec { + (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect() +} + +/// The spec's wire vectors (write-receipt-vectors.json `wire`): the bytes decode to the vector's fields, not +/// merely to something that encodes back to itself. +#[test] +fn the_spec_wire_vectors_decode_to_their_expected_fields() { + let admitted = decode_cultnet_message_from_slice( + &unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + assert_eq!( + admitted, + CultNetMessage::WriteAdmitted { + message_id: "put-1".into(), + operation: "put".into(), + schema_id: "note.v0".into(), + record_key: "note:1".into(), + } + ); + let refused = decode_cultnet_message_from_slice( + &unhex("86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32"), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + let CultNetMessage::ErrorV1 { error, code, details, message_id } = refused else { + panic!("expected error.v1"); + }; + assert_eq!(error, "write_not_authorized: the catalog refused the document"); + assert_eq!(code, Some(CultNetErrorCode::WriteNotAuthorized)); + assert_eq!(message_id.as_deref(), Some("put-2")); + let details = details.unwrap(); + assert_eq!(details.field.as_deref(), Some("recordKey")); + assert_eq!(details.value.as_deref(), Some("note:2")); + assert_eq!((details.as_of, details.current), (None, None)); +} From 5f699c27fa5504ce403a42a66cb5811855227b6e Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:27:54 +0000 Subject: [PATCH 07/31] cultmesh-rs: a put reusing a delete's id is refused (mutant-driven test) Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/tests/rudp_document_server.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 645fb5df..58e104a3 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2576,3 +2576,18 @@ fn refusal_frames_from_a_peer_are_not_protocol_violations() -> Result<()> { assert_eq!(server.session_count(), 1); Ok(()) } + +#[test] +fn a_put_that_reuses_a_deletes_id_is_message_invalid() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 223)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &delete("d1", "note:1"))?; + send_reliable(&mut peer, &put("d1", "note:1", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("d1".into()))); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the reusing put was not applied"); + Ok(()) +} From eafcdcdf7c130b9539458c3506df7f47bcfdcd08 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:29:18 +0000 Subject: [PATCH 08/31] cultnet: add limit_reached to error.v1 code enum, with captured vector Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/cultnet-distributed-database.md | 1 + .../interop/cultnet.error.v1.schema.json | 7 ++++--- .../cultnet/interop/write-receipt-vectors.json | 17 +++++++++++++++++ contracts/cultnet/interop/write-receipt.md | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 686f4892..932ba861 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -115,6 +115,7 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and `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) and +`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) 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. diff --git a/contracts/cultnet/interop/cultnet.error.v1.schema.json b/contracts/cultnet/interop/cultnet.error.v1.schema.json index 255cd6bb..cd9f47b5 100644 --- a/contracts/cultnet/interop/cultnet.error.v1.schema.json +++ b/contracts/cultnet/interop/cultnet.error.v1.schema.json @@ -45,7 +45,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).", + "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). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) is full; messageId is the write's id.", "type": [ "string", "null" @@ -60,7 +60,8 @@ "unowned_schema", "write_not_authorized", "unsupported_schema_version", - "message_invalid" + "message_invalid", + "limit_reached" ] }, "details": { @@ -78,7 +79,7 @@ ], "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\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", + "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). limit_reached: no field; details stay all null.", "type": [ "string", "null" diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index 93bfa4f3..f575398d 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -74,6 +74,23 @@ }, "messageId": null } + }, + { + "name": "error_v1_limit_reached", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "limit_reached: the session's remembered-write limit is full", + "routingHint": null, + "code": "limit_reached", + "details": { + "field": null, + "value": null, + "asOf": null, + "current": null + }, + "messageId": "put-1025" + } } ], "schemaInvalid": [ diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 90acb8bb..4b97468f 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -32,6 +32,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice. - Same key, same content hash, answer was a refusal: the same refusal again. - **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged. + - **Remembered-write limit.** The per-session table of remembered writes holds at most 1024 entries. A server whose table is full refuses a NEW write (a key not already in the table) with `limit_reached`, `messageId` the write's id, `details` all null (no field is named); the write is not applied. A resend of a remembered key is still answered from the table. - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. 6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. 7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. From 0182a6d40b48cd694fa503d53de8e499aab6b5f5 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:32:49 +0000 Subject: [PATCH 09/31] cultnet-rs, cultmesh-rs: remembered-write cap refusal is error.v1 limit_reached; pin authorize-once with a counting sink Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 5 ++ .../cultmesh-rs/tests/rudp_document_server.rs | 36 +++++++++++++- packages/cultnet-rs/src/contracts.rs | 11 +++++ packages/cultnet-rs/tests/write_receipt.rs | 47 ++++++++++++++++++- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 1b90932b..c6825c8e 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -437,6 +437,11 @@ impl CultMeshRudpApplicationRejection { (CultMeshRudpRejectionReason::MessageIdReused, _) => { message_invalid_message("messageId", text) } + (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { + error: format!("limit_reached: {text}"), + code: Some(CultNetErrorCode::LimitReached), + details: Some(CultNetErrorDetails::default()), + }, _ => CultNetMessage::Error { error: text.into(), code: None, diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 58e104a3..79e9e14c 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -24,6 +24,8 @@ struct SinkState { failures_remaining: usize, /// Record keys the sink refuses to authorize, before and at the write. denied_keys: Vec, + /// Every `authorize_raw_document` call, by message id. + authorized: Vec, } #[derive(Clone, Default)] @@ -47,6 +49,7 @@ impl Sink { impl CultMeshRudpRawDocumentSink for Sink { fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { + self.0.lock().unwrap().authorized.push(receipt.message_id.clone()); if self.0.lock().unwrap().denied_keys.contains(&receipt.document.record_key) { anyhow::bail!("not authorized"); } @@ -2328,9 +2331,9 @@ fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (None, Some("over".into()))); + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); let CultNetMessage::ErrorV1 { error, .. } = &answers[0] else { unreachable!() }; - assert_eq!(error, "the session's remembered-write limit is full", "the cap has its own fixed text"); + assert_eq!(error, "limit_reached: the session's remembered-write limit is full", "the cap has its own fixed text"); assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); assert_eq!(server.session_count(), 1); Ok(()) @@ -2477,6 +2480,35 @@ fn a_refused_write_is_remembered_and_its_resend_replays_the_refusal() -> Result< Ok(()) } +/// The sink is asked once, on an id's first sighting: not again for a resend (admitted or refused), and +/// not at all on a session that never declared the receipt capability. The sink here only counts, so a +/// flag that stopped or inverted the ask cannot hide behind the sink refusing at the write. +#[test] +fn authorize_is_called_once_per_first_sighting_and_only_for_receipt_sessions() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 219)?; + let mut legacy = client(server.local_addr()?, 220)?; + connect(&mut server, &mut [&mut peer, &mut legacy])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "first sighting asks once"); + send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); + assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "the resend does not ask again"); + send_reliable(&mut legacy, &put("l1", "note:9", vec![1]))?; + for _ in 0..40 { + server.poll_once()?; + legacy.poll_resends()?; + while legacy.receive_once()?.is_some() {} + } + let state = sink.0.lock().unwrap(); + assert_eq!(state.receipts.len(), 2, "the legacy put was committed"); + assert_eq!(state.authorized, vec!["m1".to_string()], "a legacy session is never asked"); + Ok(()) +} + #[test] fn a_delete_that_reuses_a_puts_id_is_message_invalid() -> Result<()> { let sink = Sink::default(); diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 28ce7663..1aa39d11 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -249,6 +249,9 @@ pub enum CultNetErrorCode { WriteNotAuthorized, UnsupportedSchemaVersion, MessageInvalid, + /// `cultnet.error.v1` only: the session's remembered-write limit is full. `cultnet.error.v0` neither + /// encodes nor decodes it, so its enum stays the schema's. + LimitReached, } impl CultNetErrorCode { @@ -263,6 +266,7 @@ impl CultNetErrorCode { Self::WriteNotAuthorized => "write_not_authorized", Self::UnsupportedSchemaVersion => "unsupported_schema_version", Self::MessageInvalid => "message_invalid", + Self::LimitReached => "limit_reached", } } @@ -277,6 +281,7 @@ impl CultNetErrorCode { "write_not_authorized" => Ok(Self::WriteNotAuthorized), "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), "message_invalid" => Ok(Self::MessageInvalid), + "limit_reached" => Ok(Self::LimitReached), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), } } @@ -1067,6 +1072,9 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result Result { + if let CultNetMessage::Error { code: Some(CultNetErrorCode::LimitReached), .. } = message { + return Err(anyhow!("limit_reached is a cultnet.error.v1 code; cultnet.error.v0 cannot carry it")); + } Ok(rmpv::Value::Map(match message { CultNetMessage::DocumentPutRaw { message_id, diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs index 20b3739a..4ad7a826 100644 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -1,7 +1,7 @@ //! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames. use cultnet_rs::{ - CultNetErrorCode, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, + CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, write_content_hash, }; @@ -89,3 +89,48 @@ fn the_spec_wire_vectors_decode_to_their_expected_fields() { assert_eq!(details.value.as_deref(), Some("note:2")); assert_eq!((details.as_of, details.current), (None, None)); } + +/// Spec vector `error_v1_limit_reached`: the bytes the server must send, and what they decode to. +#[test] +fn error_v1_limit_reached_is_the_spec_vector() { + let vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235"; + let message = CultNetMessage::ErrorV1 { + error: "limit_reached: the session's remembered-write limit is full".into(), + code: Some(CultNetErrorCode::LimitReached), + details: Some(CultNetErrorDetails::default()), + message_id: Some("put-1025".into()), + }; + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); + assert_eq!(hex(&bytes), vector); + let CultNetMessage::ErrorV1 { error, code, details, message_id } = + decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap() + else { + panic!("expected error.v1"); + }; + assert_eq!(error, "limit_reached: the session's remembered-write limit is full"); + assert_eq!(code, Some(CultNetErrorCode::LimitReached)); + assert_eq!(message_id.as_deref(), Some("put-1025")); + assert_eq!(details, Some(CultNetErrorDetails::default())); +} + +/// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it. +#[test] +fn error_v0_refuses_the_v1_only_limit_reached_code() { + let v0 = CultNetMessage::Error { + error: "x".into(), + code: Some(CultNetErrorCode::LimitReached), + details: None, + }; + assert!(encode_cultnet_message_to_vec(&v0, CultNetWireContract::CultNetSchemaV0).is_err()); + let v1 = CultNetMessage::ErrorV1 { + error: "x".into(), + code: Some(CultNetErrorCode::LimitReached), + details: None, + message_id: None, + }; + let v1_bytes = encode_cultnet_message_to_vec(&v1, CultNetWireContract::CultNetSchemaV0).unwrap(); + let at = v1_bytes.windows(16).position(|w| w == b"cultnet.error.v1").unwrap(); + let mut as_v0 = v1_bytes.clone(); + as_v0[at + 15] = b'0'; + assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err()); +} From b89a8837003517ea0633fe46c63d2f6e517895bf Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:58:51 +0000 Subject: [PATCH 10/31] spec: C# RUDP schema server is not receipt-capable; advertise only if end to end Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 4b97468f..3d7dd19b 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -18,7 +18,7 @@ Why a new error version and not an extra key on v0: the v0 schema is closed (`ad ## Capability: opting in -A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. +A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end. **The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in. @@ -61,6 +61,14 @@ Those clients complete *successfully* when the window ends with no refusal. Unde `write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7. +## Conformance status + +| Implementation | Receipt-capable | Hello advertises the frames | +|---|---|---| +| C# RUDP schema server (`src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | no | + +It is not receipt-capable and must not advertise the frames until it implements the rules end to end. + ## Conformance follow-ups (not in this PR) C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog. From 734087766ae84a407367239c960ea80cdb151a3f Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:04:21 +0000 Subject: [PATCH 11/31] Spec #59: id size, digest memory, limit refusals remembered, required fields, reliable channel, local send failure, per-class conformance table Co-Authored-By: Claude Sonnet 5.5 --- .../interop/cultnet.error.v1.schema.json | 4 ++-- contracts/cultnet/interop/write-receipt.md | 23 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/contracts/cultnet/interop/cultnet.error.v1.schema.json b/contracts/cultnet/interop/cultnet.error.v1.schema.json index cd9f47b5..02a1ae21 100644 --- a/contracts/cultnet/interop/cultnet.error.v1.schema.json +++ b/contracts/cultnet/interop/cultnet.error.v1.schema.json @@ -45,7 +45,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). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) is full; messageId is the write's id.", + "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). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.", "type": [ "string", "null" @@ -79,7 +79,7 @@ ], "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\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read). limit_reached: no field; details stay all null.", + "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). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).", "type": [ "string", "null" diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 3d7dd19b..5a7b90f0 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -28,11 +28,17 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir 2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission. 3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read. 4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer. -5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (a session at the bound refuses new writes with an `error.v1` carrying the new write's id, and keeps answering the ids it remembers; it never forgets one it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.** +5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.** - Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice. - Same key, same content hash, answer was a refusal: the same refusal again. - **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged. - - **Remembered-write limit.** The per-session table of remembered writes holds at most 1024 entries. A server whose table is full refuses a NEW write (a key not already in the table) with `limit_reached`, `messageId` the write's id, `details` all null (no field is named); the write is not applied. A resend of a remembered key is still answered from the table. + - **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically. + - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. + - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. + - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and its resend is refused identically). The client mints a new id on a new session. + - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. + - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. + - **Reliable channel only.** A write arriving on an unreliable channel on a receipt session is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. 6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. 7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. @@ -43,7 +49,7 @@ A write settles as exactly one of: - **admitted**: a `write_admitted` whose `messageId` is the write's. - **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. -- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first). Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. +- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first, whether the peer closed it or the client's own send failed: a local send failure carries a local-failure reason, e.g. `session_ended` / `local_send_failed`). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. **Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window. @@ -63,11 +69,14 @@ Those clients complete *successfully* when the window ends with no refusal. Unde ## Conformance status -| Implementation | Receipt-capable | Hello advertises the frames | -|---|---|---| -| C# RUDP schema server (`src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | no | +| Server class | Advertises both frames | +|---|---| +| `CultNetDatabaseServer` | yes | +| Shard write forwarder | yes | +| Rust RUDP document server | yes | +| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | -It is not receipt-capable and must not advertise the frames until it implements the rules end to end. +Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts. ## Conformance follow-ups (not in this PR) From 1447ccd8dd6846cd06bef25d60644f978bcc22cd Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:09:28 +0000 Subject: [PATCH 12/31] cultnet-rs, cultmesh-rs: write_admitted in spec field order; server-wide remembered-write budget over digest entries; id size limit; limit refusals remembered; unreliable writes refused; local failure is its own goodbye Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 218 +++++++++++++++--- .../cultmesh-rs/tests/rudp_document_server.rs | 126 +++++++++- packages/cultnet-rs/src/contracts.rs | 18 +- packages/cultnet-rs/src/rudp.rs | 10 + packages/cultnet-rs/tests/write_receipt.rs | 21 +- 5 files changed, 359 insertions(+), 34 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index c6825c8e..40dfb767 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,7 +3,7 @@ use cultnet_rs::{ CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, readable_message_id, send_error_code, write_content_hash, + encode_rudp_packet, is_permanent_send_error, readable_message_id, message_id_digest, send_error_code, write_content_hash, }; use std::collections::{BTreeMap, HashMap}; #[cfg(test)] @@ -189,6 +189,15 @@ pub struct CultMeshRudpDocumentServerOptions { pub resend_delay: Duration, pub max_pending_reliable_packets_per_session: usize, pub max_fragment_bytes: usize, + /// Server-wide memory budget for the remembered writes of all receipt sessions + /// (write-receipt.md, "Remembered-write limits"): at most this many bytes, counted as + /// `REMEMBERED_ENTRY_BYTES` per entry. A new write over it is refused `limit_reached`, the session kept. + pub max_remembered_write_bytes: usize, +} + +impl CultMeshRudpDocumentServerOptions { + /// What one remembered write is charged: the digests of its id and content, and its answer. + pub const REMEMBERED_ENTRY_BYTES: usize = 32 + 32 + 1; } impl Default for CultMeshRudpDocumentServerOptions { @@ -204,6 +213,7 @@ impl Default for CultMeshRudpDocumentServerOptions { resend_delay: Duration::from_millis(50), max_pending_reliable_packets_per_session: 1024, max_fragment_bytes: 1200, + max_remembered_write_bytes: 32 * 1024 * 1024, } } } @@ -275,6 +285,10 @@ pub enum CultMeshRudpRejectionReason { /// A receipt-capable session already remembers `MAX_REMEMBERED_WRITES_PER_SESSION` writes and takes no new /// id. Nothing reached the sink. WriteLimitReached, + /// A receipt-capable session sent a message id over `MAX_MESSAGE_ID_BYTES`. Nothing was remembered. + MessageIdTooLong, + /// A receipt-capable session sent a write on an unreliable channel, where no answer could be owed once. + ChannelNotReliable, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -285,6 +299,8 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"), Self::MessageIdReused => f.write_str("the message id was already used for different content"), Self::WriteLimitReached => f.write_str("the session remembers as many writes as it will"), + Self::MessageIdTooLong => f.write_str("the message id is longer than 256 bytes"), + Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -325,7 +341,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 13] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 15] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -339,12 +355,40 @@ pub(crate) const REFUSAL_TEXTS: [&str; 13] = [ "the message could not be read", "the message id was already used for different content", "the session's remembered-write limit is full", + "messageId exceeds 256 bytes", + "receipt sessions require the reliable channel", ]; impl CultMeshRudpRejectionReason { /// The fixed text the refused peer is sent for this variant. fn refusal_text(&self) -> &'static str { - REFUSAL_TEXTS[match self { + REFUSAL_TEXTS[usize::from(self.refusal_index())] + } + + /// The reason a remembered refusal replays as. Its payload (a sink's text, a size) is not remembered: the + /// peer is only ever sent the variant's fixed text, and the caller's rejection is reported once, at first sight. + fn from_refusal_index(index: u8) -> Self { + match index { + 0 => Self::SinkRefused(String::new()), + 1 => Self::DocumentUnservable { response_bytes: 0, max_snapshot_response_bytes: 0, fragment_count: 0, max_fragment_count: 0 }, + 2 => Self::SnapshotSourceFailed(String::new()), + 3 => Self::SnapshotTooManyDocuments { documents: 0, max_snapshot_documents: 0 }, + 4 => Self::SnapshotResponseTooLarge { response_bytes: 0, max_snapshot_response_bytes: 0 }, + 5 => Self::ResponseEncodingFailed(String::new()), + 6 => Self::PayloadBudgetFull, + 7 => Self::ResponseQueueFailed(String::new()), + 8 => Self::ResponseSendFailed(""), + 9 => Self::DeleteNotSupported, + 10 => Self::MessageUnreadable(String::new()), + 11 => Self::MessageIdReused, + 12 => Self::WriteLimitReached, + 13 => Self::MessageIdTooLong, + _ => Self::ChannelNotReliable, + } + } + + fn refusal_index(&self) -> u8 { + match self { Self::SinkRefused(_) => 0, Self::DocumentUnservable { .. } => 1, Self::SnapshotSourceFailed(_) => 2, @@ -358,7 +402,9 @@ impl CultMeshRudpRejectionReason { Self::MessageUnreadable(_) => 10, Self::MessageIdReused => 11, Self::WriteLimitReached => 12, - }] + Self::MessageIdTooLong => 13, + Self::ChannelNotReliable => 14, + } } } @@ -437,6 +483,8 @@ impl CultMeshRudpApplicationRejection { (CultMeshRudpRejectionReason::MessageIdReused, _) => { message_invalid_message("messageId", text) } + (CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text), + (CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text), (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { error: format!("limit_reached: {text}"), code: Some(CultNetErrorCode::LimitReached), @@ -475,6 +523,8 @@ enum SessionEnd { Rejected(CultNetMessage), /// The session owes its peer a packet that can never be sent as built. Unsendable(std::io::Error), + /// The server could not answer a write it owed an answer to. Not a refusal of anything the peer sent. + LocalFailure, } fn write_rejection( @@ -512,9 +562,31 @@ struct SessionEntry { /// Answered writes of a receipt-capable session: message id -> (content hash, the answer given), refusals /// included, so a resend of the same content is answered again without being decided again and the same /// id with other content is refused. - answered_writes: HashMap, + answered_writes: HashMap<[u8; 32], RememberedWrite>, + /// Writes refused `limit_reached`, kept so their resend replays the refusal. A bounded set outside the + /// entry budget (`MAX_REMEMBERED_WRITES_PER_SESSION`); past it a new write is refused unremembered. + limit_refusals: HashMap<[u8; 32], [u8; 32]>, } +/// What the server remembers of a write it answered: digests only, so an entry costs +/// `REMEMBERED_ENTRY_BYTES` whatever the id or payload size. The answer's body is rebuilt from the resend, +/// whose content hash has been checked equal to the original's. +#[derive(Clone, Copy)] +struct RememberedWrite { + content_hash: [u8; 32], + answer: StoredAnswer, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StoredAnswer { + Admitted, + /// A refusal, by its `REFUSAL_TEXTS` index (`CultMeshRudpRejectionReason::refusal_index`). + Refused(u8), +} + +/// The longest `messageId` a receipt session may send, in UTF-8 bytes. +const MAX_MESSAGE_ID_BYTES: usize = 256; + /// The most answered writes a receipt-capable session remembers; a session at the limit refuses new writes /// (`WriteLimitReached`) rather than forget an id it promised to answer idempotently. const WRITE_ADMITTED_VERSION: &str = "cultnet.write_admitted.v0"; @@ -663,7 +735,9 @@ where && packet.channel_id == "schema" && !packet.reliable { - return Ok(CultMeshRudpPollOutcome::Handled); + // A receipt session's write that could be lost without trace cannot be answered once: it is + // refused on the reliable channel, never applied. Anything else unreliable is dropped as before. + return self.refuse_unreliable_write(key, &packet.payload); } let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { packet.payload.len() @@ -789,7 +863,7 @@ where Answered::Sent => {} Answered::Unsendable(error) => unsendable = Some(error), Answered::Unqueueable => { - self.end_session(key, SessionEnd::Refused)?; + self.end_session(key, SessionEnd::LocalFailure)?; return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } } @@ -814,7 +888,7 @@ where // An unanswerable write is answered by ending the session: its peer settles it // as `session_ended`, never as silence. Answered::Unqueueable => { - self.end_session(key, SessionEnd::Refused)?; + self.end_session(key, SessionEnd::LocalFailure)?; return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } } @@ -897,6 +971,39 @@ where }) } + /// Refuses a put or delete that arrived on an unreliable channel of a receipt session with + /// `message_invalid` (`details.field` `channel`) naming its id. Nothing is remembered or applied: the id + /// stays free for the same write sent reliably. + fn refuse_unreliable_write( + &mut self, + key: CultMeshRudpSessionKey, + payload: &[u8], + ) -> Result { + if !self.receipts(key) { + return Ok(CultMeshRudpPollOutcome::Handled); + } + let Ok(message) = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0) else { + return Ok(CultMeshRudpPollOutcome::Handled); + }; + let (operation, message_id, record_key) = match message { + CultNetMessage::DocumentPutRaw { message_id, document } => { + (CultMeshRudpApplicationOperation::DocumentPutRaw, message_id, document.record_key) + } + CultNetMessage::DocumentDelete { message_id, record_key, .. } => { + (CultMeshRudpApplicationOperation::DocumentDelete, message_id, record_key) + } + _ => return Ok(CultMeshRudpPollOutcome::Handled), + }; + let rejection = write_rejection(key, operation, &message_id, &record_key, CultMeshRudpRejectionReason::ChannelNotReliable); + let refusal = rejection.refusal_message(true); + match self.send_on_live_session(key, &refusal, true)? { + Answered::Sent => {} + Answered::Unsendable(error) => self.end_session(key, SessionEnd::Unsendable(error))?, + Answered::Unqueueable => self.end_session(key, SessionEnd::LocalFailure)?, + } + Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)) + } + /// Expire idle sessions and resend reliable packets whose deadline passed. pub fn maintain(&mut self) -> Result { let now = self.clock.now_monotonic_millis(); @@ -1011,6 +1118,7 @@ where receipts: false, capability_settled: false, answered_writes: HashMap::new(), + limit_refusals: HashMap::new(), }, ); accept @@ -1067,6 +1175,9 @@ where schema_id: receipt.document.schema_id.clone(), record_key: receipt.document.record_key.clone(), }; + if message_id.len() > MAX_MESSAGE_ID_BYTES { + return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); + } let content_hash = match write_content_hash( "put", &receipt.document.schema_id, @@ -1082,20 +1193,21 @@ where }; // The id is looked up first: a resend is answered with what the session already said, and // the sink is asked only about an id the session has not seen. + let id_digest = message_id_digest(&message_id); if let ControlFlow::Break(outcome) = - self.seen_write(key, now, &message_id, content_hash, &rejection)? + self.seen_write(key, now, id_digest, content_hash, &admitted, &rejection)? { return Ok(outcome); } match self.admit_put(receipt, true) { Ok(()) => { // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. - self.remember(key, &message_id, content_hash, admitted.clone()); + self.remember(key, id_digest, content_hash, StoredAnswer::Admitted); return self.answer_sent(key, now, &admitted, &rejection); } Err(reason) => { let refused = rejection(reason); - self.remember(key, &message_id, content_hash, refused.refusal_message(true)); + self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); return Ok(Some(refused)); } } @@ -1278,6 +1390,9 @@ where if !self.receipts(key) { return Ok(Some(rejection(CultMeshRudpRejectionReason::DeleteNotSupported))); } + if message_id.len() > MAX_MESSAGE_ID_BYTES { + return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); + } let content_hash = match write_content_hash("delete", &schema_id, &record_key, None) { Ok(hash) => hash, Err(error) => { @@ -1286,13 +1401,21 @@ where )))); } }; + let id_digest = message_id_digest(&message_id); + // A delete is never admitted; the body is the receipt it would be answered with. + let would_admit = CultNetMessage::WriteAdmitted { + message_id: message_id.clone(), + operation: "delete".into(), + schema_id: schema_id.clone(), + record_key: record_key.clone(), + }; if let ControlFlow::Break(outcome) = - self.seen_write(key, now, &message_id, content_hash, &rejection)? + self.seen_write(key, now, id_digest, content_hash, &would_admit, &rejection)? { return Ok(outcome); } let refused = rejection(CultMeshRudpRejectionReason::DeleteNotSupported); - self.remember(key, &message_id, content_hash, refused.refusal_message(true)); + self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); return Ok(Some(refused)); } _ => {} @@ -1300,36 +1423,63 @@ where Ok(None) } - /// What a receipt-capable session already knows of `message_id`. `Continue` is a first sighting that - /// the session has room to remember. A repeat with the same content is answered with the stored answer, - /// a repeat with other content (a put or a delete of any record) is `MessageIdReused`, and a session - /// that remembers its limit takes no new id. + /// What a receipt-capable session already knows of a write, by the digest of its id. `Continue` is a first + /// sighting that fits both bounds. A repeat with the same content is answered with its remembered answer + /// (rebuilt from this resend, whose content hash is the original's), a repeat with other content is + /// `MessageIdReused`. A first sighting past the session table or the server-wide budget is refused + /// `limit_reached` for that write alone, and that refusal is remembered (`limit_refusals`) so its resend + /// replays it; no slot is freed, so the id is never admitted later in the session. fn seen_write( &mut self, key: CultMeshRudpSessionKey, now: u64, - message_id: &str, + id_digest: [u8; 32], content_hash: [u8; 32], + admitted: &CultNetMessage, rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, ) -> Result>> { - let (prior, remembered) = self - .sessions - .get(&key) - .map(|entry| (entry.answered_writes.get(message_id).cloned(), entry.answered_writes.len())) - .unwrap_or_default(); + let Some(entry) = self.sessions.get(&key) else { + return Ok(ControlFlow::Continue(())); + }; + let prior = entry.answered_writes.get(&id_digest).copied().or_else(|| { + entry.limit_refusals.get(&id_digest).map(|&content_hash| RememberedWrite { + content_hash, + answer: StoredAnswer::Refused(CultMeshRudpRejectionReason::WriteLimitReached.refusal_index()), + }) + }); + let session_full = entry.answered_writes.len() >= MAX_REMEMBERED_WRITES_PER_SESSION; + let refusal_room = entry.limit_refusals.len() < MAX_REMEMBERED_WRITES_PER_SESSION; + let remembered_total: usize = self.sessions.values().map(|entry| entry.answered_writes.len()).sum(); + let budget_full = remembered_total + .checked_add(1) + .and_then(|entries| entries.checked_mul(CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES)) + .is_none_or(|bytes| bytes > self.options.max_remembered_write_bytes); Ok(ControlFlow::Break(match prior { - Some((hash, answer)) if hash == content_hash => self.answer_sent(key, now, &answer, rejection)?, + Some(remembered) if remembered.content_hash == content_hash => { + let answer = match remembered.answer { + StoredAnswer::Admitted => admitted.clone(), + StoredAnswer::Refused(index) => { + rejection(CultMeshRudpRejectionReason::from_refusal_index(index)).refusal_message(true) + } + }; + self.answer_sent(key, now, &answer, rejection)? + } Some(_) => Some(rejection(CultMeshRudpRejectionReason::MessageIdReused)), - None if remembered >= MAX_REMEMBERED_WRITES_PER_SESSION => { + None if session_full || budget_full => { + if refusal_room { + if let Some(entry) = self.sessions.get_mut(&key) { + entry.limit_refusals.insert(id_digest, content_hash); + } + } Some(rejection(CultMeshRudpRejectionReason::WriteLimitReached)) } None => return Ok(ControlFlow::Continue(())), })) } - fn remember(&mut self, key: CultMeshRudpSessionKey, message_id: &str, content_hash: [u8; 32], answer: CultNetMessage) { + fn remember(&mut self, key: CultMeshRudpSessionKey, id_digest: [u8; 32], content_hash: [u8; 32], answer: StoredAnswer) { if let Some(entry) = self.sessions.get_mut(&key) { - entry.answered_writes.insert(message_id.to_owned(), (content_hash, answer)); + entry.answered_writes.insert(id_digest, RememberedWrite { content_hash, answer }); } } @@ -1346,7 +1496,7 @@ where if self.send_reliable_schema(key, now, answer) { return Ok(None); } - self.end_session(key, SessionEnd::Refused)?; + self.end_session(key, SessionEnd::LocalFailure)?; Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseSendFailed( "the write answer could not be sent".into(), )))) @@ -1463,10 +1613,12 @@ where let Some(mut entry) = self.sessions.remove(&key) else { return Ok(()); }; + let local_failure = matches!(end, SessionEnd::LocalFailure); let (refusal, mut unsendable) = match end { SessionEnd::Refused => (Vec::new(), None), SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal, false), None), SessionEnd::Unsendable(error) => (Vec::new(), Some(error)), + SessionEnd::LocalFailure => (Vec::new(), None), }; entry.session.reset_peer_state(); let nothing = entry.session.create_ack(); @@ -1478,9 +1630,10 @@ where break; } } - let goodbye = match &unsendable { - Some(error) => entry.session.end_unsendable_session(error), - None => entry.session.end_refused_session(), + let goodbye = match (&unsendable, local_failure) { + (Some(error), _) => entry.session.end_unsendable_session(error), + (None, true) => entry.session.end_local_failure_session(), + (None, false) => entry.session.end_refused_session(), }; // The goodbye is the session's last datagram. If it too can never be // sent, nobody is left to tell. @@ -1603,6 +1756,9 @@ fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { if options.session_max_lifetime.is_zero() { return Err(anyhow!("session_max_lifetime must be greater than zero")); } + if options.max_remembered_write_bytes == 0 { + return Err(anyhow!("max_remembered_write_bytes must be greater than zero")); + } if options.max_admitted_payload_bytes == 0 { return Err(anyhow!( "max_admitted_payload_bytes must be greater than zero" diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 79e9e14c..0957d008 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2332,8 +2332,9 @@ fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; let answers = frames_after(&mut server, &mut peer, 2)?; assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - let CultNetMessage::ErrorV1 { error, .. } = &answers[0] else { unreachable!() }; + let CultNetMessage::ErrorV1 { error, details, .. } = &answers[0] else { unreachable!() }; assert_eq!(error, "limit_reached: the session's remembered-write limit is full", "the cap has its own fixed text"); + assert_eq!(details, &Some(CultNetErrorDetails::default()), "limit_reached names no field: details all null"); assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); assert_eq!(server.session_count(), 1); Ok(()) @@ -2423,6 +2424,17 @@ fn a_receipt_that_cannot_be_queued_ends_the_session_instead_of_leaving_the_write } } assert_eq!(server.session_count(), 0, "a receipt the queue could not take ended the session"); + // The goodbye names a local failure: it is not the "session refused a packet" goodbye of a protocol refusal. + let mut reason = None; + for _ in 0..200 { + while peer.receive_once()?.is_some() {} + if let Some(found) = peer.disconnect_reason() { + reason = Some(found.to_vec()); + break; + } + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(reason.expect("the peer was told the session ended"), b"server could not answer a write".to_vec()); Ok(()) } @@ -2623,3 +2635,115 @@ fn a_put_that_reuses_a_deletes_id_is_message_invalid() -> Result<()> { assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the reusing put was not applied"); Ok(()) } + +fn error_text_and_field(message: &CultNetMessage) -> (String, Option) { + let CultNetMessage::ErrorV1 { error, details, .. } = message else { panic!("expected error.v1, got {message:?}") }; + (error.clone(), details.as_ref().and_then(|d| d.field.clone())) +} + +/// The server-wide budget counts fixed-size entries across sessions; a write over it is refused for that write +/// alone, the refusal is remembered (a resend replays it, no slot is freed), and every session is kept. +#[test] +fn a_server_wide_memory_budget_refuses_the_write_over_it_and_keeps_the_sessions() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: 2 * CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; + let mut a = client(server.local_addr()?, 230)?; + let mut b = client(server.local_addr()?, 231)?; + connect(&mut server, &mut [&mut a, &mut b])?; + send_reliable(&mut a, &receipt_hello())?; + send_reliable(&mut b, &receipt_hello())?; + send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; + send_reliable(&mut b, &put("b1", "note:b1", vec![1]))?; + for _ in 0..200 { + server.poll_once()?; + a.poll_resends()?; + b.poll_resends()?; + while a.receive_once()?.is_some() {} + while b.receive_once()?.is_some() {} + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); + send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; + send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; + let answers = frames_after(&mut server, &mut a, 2)?; + for answer in &answers { + assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::LimitReached), Some("a2".into())), "the resend replays the refusal"); + let CultNetMessage::ErrorV1 { details, .. } = answer else { unreachable!() }; + assert_eq!(details, &Some(CultNetErrorDetails::default())); + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), 2, "the refused write was not applied"); + assert_eq!(server.session_count(), 2, "both sessions are kept"); + send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut a, 1)?, vec![admitted("a1", "note:a1")], "a remembered id is still answered"); + Ok(()) +} + +/// A message id over 256 bytes is refused before anything is remembered: no entry, no budget charge. +#[test] +fn a_message_id_over_256_bytes_is_refused_before_anything_is_remembered() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 232)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + let long = "x".repeat(257); + send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; + send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; + send_reliable(&mut peer, &put(&"y".repeat(256), "note:2", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 3)?; + for answer in &answers[..2] { + assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some(long.clone()))); + assert_eq!(error_text_and_field(answer), ("message_invalid: messageId exceeds 256 bytes".into(), Some("messageId".into()))); + } + assert_eq!(answers[2], admitted(&"y".repeat(256), "note:2"), "256 bytes is allowed, and the long id charged no budget"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the long-id writes were never applied"); + Ok(()) +} + +/// A write on an unreliable channel of a receipt session is refused, not dropped and not accepted. +#[test] +fn an_unreliable_write_on_a_receipt_session_is_refused_message_invalid_naming_the_channel() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 233)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + let mut answers = Vec::new(); + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); + } + } + let refusals: Vec<_> = answers.iter().filter(|answer| matches!(answer, CultNetMessage::ErrorV1 { .. })).collect(); + assert_eq!(refusals.len(), 1, "the unreliable write is answered, not dropped: {answers:?}"); + assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("u1".into()))); + assert_eq!( + error_text_and_field(refusals[0]), + ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) + ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); + assert_eq!(server.session_count(), 1); + // The id was not consumed: the same write on the reliable channel is admitted. + peer.send(&put("u1", "note:1", vec![1]))?; + server.poll_once()?; + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); + Ok(()) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 1aa39d11..531fe7ec 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -569,7 +569,8 @@ pub fn encode_cultnet_message_for_wire( | CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } | CultNetMessage::Error { .. } - | CultNetMessage::ErrorV1 { .. } => encode_raw_cultnet_schema_message(message), + | CultNetMessage::ErrorV1 { .. } + | CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec( &serde_json::to_value(message)?, )?)?), @@ -1146,6 +1147,14 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ + (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.write_admitted.v0")), + (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), + (rmpv::Value::from("operation"), rmpv::Value::from(operation.as_str())), + (rmpv::Value::from("schemaId"), rmpv::Value::from(schema_id.as_str())), + (rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())), + ], CultNetMessage::DocumentPutRaw { message_id, document, @@ -1991,6 +2000,13 @@ pub fn write_content_hash( Ok(Sha256::digest(&bytes).into()) } +/// SHA-256 of a write's `messageId`: what a receipt server remembers in place of the id itself, so an +/// entry's memory does not depend on the id's length (write-receipt.md, "What is remembered"). +pub fn message_id_digest(message_id: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + Sha256::digest(message_id.as_bytes()).into() +} + /// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal /// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not /// a MessagePack map or hold no non-empty string `messageId`. diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 6a2aa20b..eae7a2ee 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -468,6 +468,14 @@ impl CultNetRudpSession { self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec()) } + /// Ends a session because the server itself could not answer a write it owes an answer to (its answer + /// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the + /// "session refused a packet" of a protocol refusal. + pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket { + self.reset_peer_state(); + self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec()) + } + /// Ends a session that owes its peer a packet that can never be sent as /// built (see `is_permanent_send_error`). Resending it would fail forever, /// and dropping it would leave a reliable sequence the peer waits on for @@ -2609,6 +2617,8 @@ where /// Sent to a peer whose session refused one of its packets, and reported to /// the local caller as the disconnect reason. const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; +/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`). +const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; /// Prefix of the goodbye reason for a session that owed a packet which can /// never be sent; the failure's fixed name (`send_error_code`) follows it. diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs index 4ad7a826..6d06a68c 100644 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -101,7 +101,7 @@ fn error_v1_limit_reached_is_the_spec_vector() { message_id: Some("put-1025".into()), }; let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!(hex(&bytes), vector); + assert_eq!(bytes, unhex(vector), "encoded bytes must equal the spec vector"); let CultNetMessage::ErrorV1 { error, code, details, message_id } = decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap() else { @@ -113,6 +113,25 @@ fn error_v1_limit_reached_is_the_spec_vector() { assert_eq!(details, Some(CultNetErrorDetails::default())); } +/// Spec vectors `write_admitted_put` and `write_admitted_delete`: the encoder emits the spec's field order. +#[test] +fn write_admitted_encodes_to_the_spec_vector_bytes() { + let cases = [ + ("put-1", "put", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), + ("del-1", "delete", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), + ]; + for (id, operation, vector) in cases { + let message = CultNetMessage::WriteAdmitted { + message_id: id.into(), + operation: operation.into(), + schema_id: "note.v0".into(), + record_key: "note:1".into(), + }; + let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); + assert_eq!(bytes, unhex(vector)); + } +} + /// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it. #[test] fn error_v0_refuses_the_v1_only_limit_reached_code() { From d7b36416c6b2530dea3277771795f67f99d6ef0a Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:30:36 +0000 Subject: [PATCH 13/31] cultmesh-rs: pin limit-refusal memory bound and legacy unreliable drop (mutant-driven tests) Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/tests/rudp_document_server.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 0957d008..5dcee048 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2747,3 +2747,62 @@ fn an_unreliable_write_on_a_receipt_session_is_refused_message_invalid_naming_th assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); Ok(()) } + +/// A limit refusal is remembered like any refusal: the same id with other content is a reused id, not a fresh +/// limit refusal; and the remembered refusals are a bounded set (1024), past which a refusal is not remembered. +#[test] +fn limit_refusals_are_remembered_in_a_bounded_set() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + ..Default::default() + }; + let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 234)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + send_reliable(&mut peer, &put("full", "note:full", vec![1]))?; + for index in 0..1024 { + send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; + if index % 16 == 15 { + for _ in 0..40 { + server.poll_once()?; + peer.poll_resends()?; + while peer.receive_once()?.is_some() {} + } + } + } + // r0 was remembered as refused: other content under its id is a reused id. + send_reliable(&mut peer, &put("r0", "note:other", vec![2]))?; + let answers = frames_after(&mut server, &mut peer, 1)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("r0".into()))); + // The 1025th refusal found the set full: it was refused but not remembered. + send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; + send_reliable(&mut peer, &put("over", "note:over", vec![2]))?; + let answers = frames_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "not remembered: refused afresh"); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +/// A session that never declared the capability gets no refusal for an unreliable write: it is dropped as before. +#[test] +fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 235)?; + let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + assert!(peer.drain().is_empty(), "a legacy session is sent nothing"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); + Ok(()) +} From c8f869e54e558d3d189446fa57233d5d2d4312d5 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:32:03 +0000 Subject: [PATCH 14/31] cultnet-rs, cultmesh-rs: null or missing document/schemaId refused naming the field; budget tests use literal bytes; unreliable-refusal local failure test Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 47 ++++++++++--- .../cultmesh-rs/tests/rudp_document_server.rs | 68 ++++++++++++++++++- packages/cultnet-rs/src/contracts.rs | 26 +++++++ 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 40dfb767..2b320578 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,7 +3,7 @@ use cultnet_rs::{ CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, readable_message_id, message_id_digest, send_error_code, write_content_hash, + encode_rudp_packet, is_permanent_send_error, readable_message_id, message_id_digest, missing_required_write_field, send_error_code, write_content_hash, }; use std::collections::{BTreeMap, HashMap}; #[cfg(test)] @@ -289,6 +289,10 @@ pub enum CultMeshRudpRejectionReason { MessageIdTooLong, /// A receipt-capable session sent a write on an unreliable channel, where no answer could be owed once. ChannelNotReliable, + /// A receipt-capable session's put or delete has a null or missing `document`. + DocumentMissing, + /// A receipt-capable session's put or delete has a null or missing `schemaId`. + SchemaIdMissing, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -301,6 +305,8 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::WriteLimitReached => f.write_str("the session remembers as many writes as it will"), Self::MessageIdTooLong => f.write_str("the message id is longer than 256 bytes"), Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"), + Self::DocumentMissing => f.write_str("the write has no document"), + Self::SchemaIdMissing => f.write_str("the write has no schemaId"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -341,7 +347,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 15] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -357,6 +363,8 @@ pub(crate) const REFUSAL_TEXTS: [&str; 15] = [ "the session's remembered-write limit is full", "messageId exceeds 256 bytes", "receipt sessions require the reliable channel", + "document is required", + "schemaId is required", ]; impl CultMeshRudpRejectionReason { @@ -383,7 +391,9 @@ impl CultMeshRudpRejectionReason { 11 => Self::MessageIdReused, 12 => Self::WriteLimitReached, 13 => Self::MessageIdTooLong, - _ => Self::ChannelNotReliable, + 14 => Self::ChannelNotReliable, + 15 => Self::DocumentMissing, + _ => Self::SchemaIdMissing, } } @@ -404,6 +414,8 @@ impl CultMeshRudpRejectionReason { Self::WriteLimitReached => 12, Self::MessageIdTooLong => 13, Self::ChannelNotReliable => 14, + Self::DocumentMissing => 15, + Self::SchemaIdMissing => 16, } } } @@ -484,6 +496,8 @@ impl CultMeshRudpApplicationRejection { message_invalid_message("messageId", text) } (CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text), + (CultMeshRudpRejectionReason::DocumentMissing, _) => message_invalid_message("document", text), + (CultMeshRudpRejectionReason::SchemaIdMissing, _) => message_invalid_message("schemaId", text), (CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text), (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { error: format!("limit_reached: {text}"), @@ -844,13 +858,26 @@ where now_unix, now_monotonic, )?, - Err(error) => Some(CultMeshRudpApplicationRejection { - session: key, - operation: CultMeshRudpApplicationOperation::Unreadable, - message_id: readable_message_id(&frame.payload).unwrap_or_default(), - record_key: None, - reason: CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")), - }), + Err(error) => { + // A receipt session's put or delete missing a required field is a refused write that + // names the field, not an unreadable frame. + let missing = if receipt_session { missing_required_write_field(&frame.payload) } else { None }; + Some(CultMeshRudpApplicationRejection { + session: key, + operation: match missing { + Some(("put", _)) => CultMeshRudpApplicationOperation::DocumentPutRaw, + Some(_) => CultMeshRudpApplicationOperation::DocumentDelete, + None => CultMeshRudpApplicationOperation::Unreadable, + }, + message_id: readable_message_id(&frame.payload).unwrap_or_default(), + record_key: None, + reason: match missing { + Some((_, "document")) => CultMeshRudpRejectionReason::DocumentMissing, + Some(_) => CultMeshRudpRejectionReason::SchemaIdMissing, + None => CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")), + }, + }) + } }; if let Some(rejection) = rejection { // A refused delete or unreadable frame below the cap is answered on the live session, diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 5dcee048..9a8c4ed3 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2646,7 +2646,7 @@ fn error_text_and_field(message: &CultNetMessage) -> (String, Option) { #[test] fn a_server_wide_memory_budget_refuses_the_write_over_it_and_keeps_the_sessions() -> Result<()> { let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 2 * CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + max_remembered_write_bytes: 2 * 65, ..Default::default() }; let sink = Sink::default(); @@ -2685,7 +2685,7 @@ fn a_server_wide_memory_budget_refuses_the_write_over_it_and_keeps_the_sessions( #[test] fn a_message_id_over_256_bytes_is_refused_before_anything_is_remembered() -> Result<()> { let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + max_remembered_write_bytes: 65, ..Default::default() }; let sink = Sink::default(); @@ -2753,7 +2753,7 @@ fn an_unreliable_write_on_a_receipt_session_is_refused_message_invalid_naming_th #[test] fn limit_refusals_are_remembered_in_a_bounded_set() -> Result<()> { let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES, + max_remembered_write_bytes: 65, ..Default::default() }; let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; @@ -2806,3 +2806,65 @@ fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); Ok(()) } + +/// A put or delete whose document or schemaId is null or missing is refused `message_invalid` naming that +/// field (document first), with the spec's fixed text and the write's id; the session goes on. +#[test] +fn a_write_missing_its_document_or_schema_id_is_refused_naming_the_field() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 236)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + let frames = [ + ("p-missing", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-missing"}), "document"), + ("p-null", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-null", "document": null}), "document"), + ("p-noschema", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-noschema", + "document": {"recordKey": "k", "storedAt": "t", "payloadEncoding": "messagepack", "payload": [1]}}), "schemaId"), + ("d-null", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-null", "schemaId": null, "recordKey": "k"}), "schemaId"), + ("d-missing", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-missing", "recordKey": "k"}), "schemaId"), + ]; + for (_, frame, _) in &frames { + peer.send_reliable("schema", rmp_serde::to_vec_named(frame)?)?; + } + let answers = answers_after(&mut server, &mut peer, frames.len())?; + for ((id, _, field), answer) in frames.iter().zip(&answers) { + assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some((*id).into()))); + assert_eq!( + error_text_and_field(answer), + (format!("message_invalid: {field} is required"), Some((*field).into())) + ); + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); + assert_eq!(server.session_count(), 1); + Ok(()) +} + +/// An unreliable write whose refusal the full reliable queue cannot take ends the session as a local failure +/// (the peer's write is then unconfirmed, never silent and never "refused a packet"). +#[test] +fn an_unreliable_refusal_that_cannot_be_queued_ends_the_session_as_a_local_failure() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { max_pending_reliable_packets_per_session: 3, ..Default::default() }; + let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 237)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + // The peer acknowledges nothing, so each refusal stays in the server's reliable queue. + for index in 0..8 { + let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + } + assert_eq!(server.session_count(), 0, "the refusal the queue could not take ended the session"); + let goodbye = peer.drain().into_iter().find(is_goodbye).expect("the peer is told the session ended"); + assert_eq!(goodbye.payload, b"server could not answer a write".to_vec()); + Ok(()) +} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 531fe7ec..0ea2aa53 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -2020,3 +2020,29 @@ pub fn readable_message_id(bytes: &[u8]) -> Option { _ => None, }) } + +/// The first required field (`document`, then `schemaId`) that a put or delete frame lacks or holds as nil +/// (write-receipt.md, "Required fields"), with the write's operation (`"put"` or `"delete"`). `None` when the +/// bytes are not such a frame, or both fields are present. A put's `schemaId` is its document's. +pub fn missing_required_write_field(bytes: &[u8]) -> Option<(&'static str, &'static str)> { + let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &*bytes).ok()? else { + return None; + }; + let field = |entries: &[(rmpv::Value, rmpv::Value)], name: &str| { + entries + .iter() + .find(|(key, _)| key.as_str() == Some(name)) + .map(|(_, value)| value.clone()) + .filter(|value| !value.is_nil()) + }; + match field(&entries, "schemaVersion")?.as_str()? { + "cultnet.document_put_raw.v0" => match field(&entries, "document") { + Some(rmpv::Value::Map(document)) => { + field(&document, "schemaId").is_none().then_some(("put", "schemaId")) + } + _ => Some(("put", "document")), + }, + "cultnet.document_delete.v0" => field(&entries, "schemaId").is_none().then_some(("delete", "schemaId")), + _ => None, + } +} From 3443e1dc939cf9676ea1aad5b0c7126f3376f790 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:34:19 +0000 Subject: [PATCH 15/31] Spec #59: error.v1 code enum names limit_reached; server-wide budget is a conformance column; an overflowed refusal set refuses unseen ids for the session Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 5a7b90f0..c87e66ac 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -12,7 +12,7 @@ A put or delete today has two answers on the wire: nothing (admitted, or the ser **`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write. -**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id. +**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id. Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders. @@ -35,7 +35,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically. - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and its resend is refused identically). The client mints a new id on a new session. + - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - **Reliable channel only.** A write arriving on an unreliable channel on a receipt session is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. @@ -69,12 +69,12 @@ Those clients complete *successfully* when the window ends with no refusal. Unde ## Conformance status -| Server class | Advertises both frames | -|---|---| -| `CultNetDatabaseServer` | yes | -| Shard write forwarder | yes | -| Rust RUDP document server | yes | -| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | +| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) | +|---|---|---| +| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies | +| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies | +| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) | +| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a | Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts. From 2c5203dc82a11014ce9a7cbe2a07440fe12574b5 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:34:52 +0000 Subject: [PATCH 16/31] Spec #59: local_send_failed is its own unconfirmed reason Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index c87e66ac..d99959c6 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -49,7 +49,7 @@ A write settles as exactly one of: - **admitted**: a `write_admitted` whose `messageId` is the write's. - **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. -- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window) and `session_ended` (the session ended first, whether the peer closed it or the client's own send failed: a local send failure carries a local-failure reason, e.g. `session_ended` / `local_send_failed`). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. +- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. **Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window. From 1a05df2524e87f095f0e94f41a291ac259a79f21 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:36:10 +0000 Subject: [PATCH 17/31] cultmesh-rs: server-wide remembered-write budget is a maintained counter; a session whose refusal set overflowed refuses unseen ids after the budget frees Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 52 +++++-- .../cultmesh-rs/tests/rudp_document_server.rs | 137 ++++++++++++++++++ 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 2b320578..a06c4773 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -580,6 +580,10 @@ struct SessionEntry { /// Writes refused `limit_reached`, kept so their resend replays the refusal. A bounded set outside the /// entry budget (`MAX_REMEMBERED_WRITES_PER_SESSION`); past it a new write is refused unremembered. limit_refusals: HashMap<[u8; 32], [u8; 32]>, + /// A limit refusal was answered that `limit_refusals` had no room to remember. That id's resend cannot be + /// told from a new write, so the session refuses every id it has not seen for the rest of its life, even once + /// the server-wide budget has freed: an id refused at a bound is never admitted later. + limit_overflowed: bool, } /// What the server remembers of a write it answered: digests only, so an entry costs @@ -619,6 +623,9 @@ pub struct CultMeshRudpDocumentServer { snapshot_source: Q, clock: C, options: CultMeshRudpDocumentServerOptions, + /// Entries remembered across all live sessions (`answered_writes`), kept in step by `remember` and + /// `drop_session`, so the server-wide budget is a comparison, not a sum over sessions per write. + remembered_writes: usize, packets_dropped: u64, send_failures: u64, /// Peers whose every datagram fails to send, standing in for an unroutable @@ -655,6 +662,7 @@ where snapshot_source, clock, options, + remembered_writes: 0, packets_dropped: 0, send_failures: 0, #[cfg(test)] @@ -803,7 +811,7 @@ where }; if result.disconnected { - self.sessions.remove(&key); + self.drop_session(key); return Ok(CultMeshRudpPollOutcome::Handled); } @@ -1037,10 +1045,16 @@ where let idle_timeout_ms = duration_millis(self.options.session_idle_timeout); let lifetime_ms = duration_millis(self.options.session_max_lifetime); let before = self.sessions.len(); + let mut freed = 0; self.sessions.retain(|_, entry| { - now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms - && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms + let live = now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms + && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms; + if !live { + freed += entry.answered_writes.len(); + } + live }); + self.remembered_writes -= freed; let expired = before - self.sessions.len(); self.end_answered_sessions()?; @@ -1106,7 +1120,7 @@ where .get(&key) .is_some_and(|entry| !entry.session.connect_repeats(packet)) { - self.sessions.remove(&key); + self.drop_session(key); } let reply = match self.sessions.get_mut(&key) { Some(entry) => { @@ -1146,6 +1160,7 @@ where capability_settled: false, answered_writes: HashMap::new(), limit_refusals: HashMap::new(), + limit_overflowed: false, }, ); accept @@ -1455,7 +1470,8 @@ where /// (rebuilt from this resend, whose content hash is the original's), a repeat with other content is /// `MessageIdReused`. A first sighting past the session table or the server-wide budget is refused /// `limit_reached` for that write alone, and that refusal is remembered (`limit_refusals`) so its resend - /// replays it; no slot is freed, so the id is never admitted later in the session. + /// replays it; no slot is freed, so the id is never admitted later in the session. A refusal the bounded set had no + /// room for sets `limit_overflowed`, after which the session refuses every unseen id, budget or no budget. fn seen_write( &mut self, key: CultMeshRudpSessionKey, @@ -1476,8 +1492,8 @@ where }); let session_full = entry.answered_writes.len() >= MAX_REMEMBERED_WRITES_PER_SESSION; let refusal_room = entry.limit_refusals.len() < MAX_REMEMBERED_WRITES_PER_SESSION; - let remembered_total: usize = self.sessions.values().map(|entry| entry.answered_writes.len()).sum(); - let budget_full = remembered_total + let overflowed = entry.limit_overflowed; + let budget_full = self.remembered_writes .checked_add(1) .and_then(|entries| entries.checked_mul(CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES)) .is_none_or(|bytes| bytes > self.options.max_remembered_write_bytes); @@ -1492,10 +1508,12 @@ where self.answer_sent(key, now, &answer, rejection)? } Some(_) => Some(rejection(CultMeshRudpRejectionReason::MessageIdReused)), - None if session_full || budget_full => { - if refusal_room { - if let Some(entry) = self.sessions.get_mut(&key) { + None if session_full || budget_full || overflowed => { + if let Some(entry) = self.sessions.get_mut(&key) { + if refusal_room { entry.limit_refusals.insert(id_digest, content_hash); + } else { + entry.limit_overflowed = true; } } Some(rejection(CultMeshRudpRejectionReason::WriteLimitReached)) @@ -1506,10 +1524,20 @@ where fn remember(&mut self, key: CultMeshRudpSessionKey, id_digest: [u8; 32], content_hash: [u8; 32], answer: StoredAnswer) { if let Some(entry) = self.sessions.get_mut(&key) { - entry.answered_writes.insert(id_digest, RememberedWrite { content_hash, answer }); + if entry.answered_writes.insert(id_digest, RememberedWrite { content_hash, answer }).is_none() { + self.remembered_writes += 1; + } } } + /// Removes a session for good and gives its remembered writes back to the server-wide budget. A session taken + /// out and put back within one call is not dropped. + fn drop_session(&mut self, key: CultMeshRudpSessionKey) -> Option { + let entry = self.sessions.remove(&key)?; + self.remembered_writes -= entry.answered_writes.len(); + Some(entry) + } + /// Sends the answer owed a write. An answer that cannot be queued or sent would leave the write /// unanswered, and a different answer would contradict what the sink did; so the session ends instead, /// and the peer's write settles as unconfirmed (`session_ended`). The answer stays remembered. @@ -1637,7 +1665,7 @@ where /// that can never be sent as built is not dropped: the goodbye names its /// error instead. A refusal that cannot be encoded is not sent. fn end_session(&mut self, key: CultMeshRudpSessionKey, end: SessionEnd) -> Result<()> { - let Some(mut entry) = self.sessions.remove(&key) else { + let Some(mut entry) = self.drop_session(key) else { return Ok(()); }; let local_failure = matches!(end, SessionEnd::LocalFailure); diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 9a8c4ed3..16fc2d6b 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2785,6 +2785,143 @@ fn limit_refusals_are_remembered_in_a_bounded_set() -> Result<()> { Ok(()) } +/// A refusal the bounded set could not hold is still the session's first answer for that id: when the budget +/// frees (another session ends), the id is not admitted later in the session, and neither is any id the session +/// has not been seen to use, because the refusal that was not remembered cannot be told from a new write. +#[test] +fn a_session_whose_limit_refusals_overflowed_refuses_new_ids_after_the_budget_frees() -> Result<()> { + let clock = Clock::new(70_000); + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: 65, + session_max_lifetime: Duration::from_millis(100), + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; + let mut holder = client(server.local_addr()?, 240)?; + connect(&mut server, &mut [&mut holder])?; + send_reliable(&mut holder, &receipt_hello())?; + send_reliable(&mut holder, &put("held", "note:held", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut holder, 2)?[1], admitted("held", "note:held"), "after the server's hello"); + + clock.set_monotonic(70_050); + let mut peer = client(server.local_addr()?, 241)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + for index in 0..1024 { + send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; + if index % 16 == 15 { + for _ in 0..40 { + server.poll_once()?; + peer.poll_resends()?; + while peer.receive_once()?.is_some() {} + } + } + } + send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; + let answers = frames_after(&mut server, &mut peer, 1)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); + + clock.set_monotonic(70_101); + assert_eq!(server.maintain()?.sessions_expired, 1, "the holder ended and freed the budget"); + assert_eq!(server.session_count(), 1); + send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; + send_reliable(&mut peer, &put("fresh", "note:fresh", vec![1]))?; + let answers = frames_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "never admitted later"); + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("fresh".into()))); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "only the holder's write was applied"); + Ok(()) +} + +/// A session that ends gives its remembered entries back to the server-wide budget, whichever way it ends. +#[test] +fn a_session_that_ends_frees_its_remembered_writes_from_the_budget() -> Result<()> { + let clock = Clock::new(70_000); + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: 65, + session_max_lifetime: Duration::from_millis(100), + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; + let mut first = client(server.local_addr()?, 242)?; + connect(&mut server, &mut [&mut first])?; + send_reliable(&mut first, &receipt_hello())?; + send_reliable(&mut first, &put("a", "note:a", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a"), "after the server's hello"); + + clock.set_monotonic(70_050); + let mut second = client(server.local_addr()?, 243)?; + connect(&mut server, &mut [&mut second])?; + send_reliable(&mut second, &receipt_hello())?; + send_reliable(&mut second, &put("b", "note:b", vec![1]))?; + assert_eq!(refusal_of(&frames_after(&mut server, &mut second, 2)?[1]).0, Some(CultNetErrorCode::LimitReached)); + + clock.set_monotonic(70_101); + assert_eq!(server.maintain()?.sessions_expired, 1); + send_reliable(&mut second, &put("c", "note:c", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut second, 1)?, vec![admitted("c", "note:c")], "the ended session's entry was freed"); + clock.set_monotonic(70_152); + assert_eq!(server.maintain()?.sessions_expired, 1); + assert_eq!(server.session_count(), 0); + let mut third = client(server.local_addr()?, 244)?; + connect(&mut server, &mut [&mut third])?; + send_reliable(&mut third, &receipt_hello())?; + send_reliable(&mut third, &put("d", "note:d", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("d", "note:d"), "and so was the second's"); + Ok(()) +} + +/// Whichever way a session ends, its remembered writes go back to the budget: here the peer says goodbye, and +/// here the server ends it itself (an unreliable write whose refusal the reliable queue cannot take). +#[test] +fn a_disconnected_or_server_ended_session_frees_its_remembered_writes() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { + max_remembered_write_bytes: 65, + max_pending_reliable_packets_per_session: 3, + ..Default::default() + }; + let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; + let mut first = client(server.local_addr()?, 245)?; + connect(&mut server, &mut [&mut first])?; + send_reliable(&mut first, &receipt_hello())?; + send_reliable(&mut first, &put("a", "note:a", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a")); + first.disconnect(Vec::new())?; + for _ in 0..20 { + server.poll_once()?; + } + assert_eq!(server.session_count(), 0, "the goodbye ended the session"); + + // The budget holds one entry. A raw peer is admitted into it, then ended by the server. + let mut second = RawPeer::connect(&mut server, 246)?; + second.send(&receipt_hello())?; + second.send(&put("b", "note:b", vec![1]))?; + server.poll_once()?; + for index in 0..8 { + let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = second.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + second.send_packet(packet)?; + } + server.poll_once()?; + } + assert_eq!(server.session_count(), 0, "the server ended the session"); + + let mut third = client(server.local_addr()?, 247)?; + connect(&mut server, &mut [&mut third])?; + send_reliable(&mut third, &receipt_hello())?; + send_reliable(&mut third, &put("c", "note:c", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("c", "note:c"), "both ended sessions' entries were freed"); + Ok(()) +} + /// A session that never declared the capability gets no refusal for an unreliable write: it is dropped as before. #[test] fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { From 74adad916ab25c605cd964d73dea7c6016b4f2be Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:59:39 +0000 Subject: [PATCH 18/31] Spec #59: receipt writes need a reliable, non-sequenced channel; error.v1 schema description names limit_reached; #55-#58 paragraph states which clients still window-succeed Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/cultnet.error.v1.schema.json | 2 +- contracts/cultnet/interop/write-receipt.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/cultnet/interop/cultnet.error.v1.schema.json b/contracts/cultnet/interop/cultnet.error.v1.schema.json index 02a1ae21..60f24a56 100644 --- a/contracts/cultnet/interop/cultnet.error.v1.schema.json +++ b/contracts/cultnet/interop/cultnet.error.v1.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json", "title": "CultNet Error Message v1", - "description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum.", + "description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).", "type": "object", "required": [ "schemaVersion", diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index d99959c6..ce708ca6 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -38,7 +38,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - - **Reliable channel only.** A write arriving on an unreliable channel on a receipt session is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. + - **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. 6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. 7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. @@ -61,7 +61,7 @@ A write settles as exactly one of: ## Changes to what #55 to #58 shipped -Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The conformance work changes it; until then the window-success is the interim, and callers must not read it as admission. +Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so; the C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. ## Vectors From e4b67a9e0bd57ade1917964b68c059a2a8fc4dd0 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:00:48 +0000 Subject: [PATCH 19/31] cultmesh-rs: a receipt session accepts writes only on a reliable, non-sequenced channel; a test pins that a reconnect starts a fresh session Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 9 +- .../cultmesh-rs/tests/rudp_document_server.rs | 91 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index a06c4773..4df5cd31 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -755,10 +755,11 @@ where } if packet.packet_type == CultNetRudpPacketType::Data && packet.channel_id == "schema" - && !packet.reliable + && (!packet.reliable || (packet.sequenced && self.receipts(key))) { - // A receipt session's write that could be lost without trace cannot be answered once: it is - // refused on the reliable channel, never applied. Anything else unreliable is dropped as before. + // A receipt session's write that could be lost without trace (unreliable, or on a sequenced channel + // that drops superseded packets) cannot be answered once: it is refused, never applied. Anything + // else unreliable is dropped as before. return self.refuse_unreliable_write(key, &packet.payload); } let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { @@ -1006,7 +1007,7 @@ where }) } - /// Refuses a put or delete that arrived on an unreliable channel of a receipt session with + /// Refuses a put or delete that arrived on an unreliable or sequenced channel of a receipt session with /// `message_invalid` (`details.field` `channel`) naming its id. Nothing is remembered or applied: the id /// stays free for the same write sent reliably. fn refuse_unreliable_write( diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 16fc2d6b..ac43c21d 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2922,6 +2922,97 @@ fn a_disconnected_or_server_ended_session_frees_its_remembered_writes() -> Resul Ok(()) } +/// Receipts need a channel that is reliable and not sequenced: a reliable-sequenced write is refused like an +/// unreliable one, a reliable-unordered write is admitted, and a legacy session's sequenced write is left alone. +#[test] +fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 248)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let send_with = |peer: &mut RawPeer, server: &mut Server, id: &str, ordered: bool, sequenced: bool| -> Result> { + let payload = encode_cultnet_message_to_vec(&put(id, "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + let mut answers = Vec::new(); + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); + } + } + Ok(answers) + }; + let sequenced = send_with(&mut peer, &mut server, "s1", false, true)?; + let refusals: Vec<_> = sequenced.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect(); + assert_eq!(refusals.len(), 1, "the sequenced write is answered: {sequenced:?}"); + assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into()))); + assert_eq!( + error_text_and_field(refusals[0]), + ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) + ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); + + let unordered = send_with(&mut peer, &mut server, "s1", false, false)?; + assert!(unordered.contains(&admitted("s1", "note:1")), "reliable unordered is accepted, and the id was not consumed: {unordered:?}"); + // A fresh peer: an ordered stream waits on the sequence numbers the refused packet used. + let mut ordered_peer = RawPeer::connect(&mut server, 251)?; + ordered_peer.send(&receipt_hello())?; + server.poll_once()?; + let ordered = send_with(&mut ordered_peer, &mut server, "o1", true, false)?; + assert!(ordered.contains(&admitted("o1", "note:1")), "reliable ordered is accepted: {ordered:?}"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); + + let legacy_sink = Sink::default(); + let mut legacy_server: Server = CultMeshRudpDocumentServer::new( + UdpSocket::bind("127.0.0.1:0")?, + legacy_sink.clone(), + Source::default(), + Clock::new(70_000), + Default::default(), + )?; + let mut legacy = RawPeer::connect(&mut legacy_server, 249)?; + let none = send_with(&mut legacy, &mut legacy_server, "l1", false, true)?; + assert!(none.is_empty(), "a legacy session is sent nothing: {none:?}"); + assert_eq!(legacy_sink.0.lock().unwrap().receipts.len(), 1, "and its reliable-sequenced write is applied as before"); + Ok(()) +} + +/// A Connect that does not repeat the accepted one is a new client session: it starts with nothing remembered, so +/// the id a receipt session already used is a new write on it, and the old session's entries leave the budget. +#[test] +fn a_reconnect_is_a_new_receipt_session_that_remembers_nothing_of_the_old() -> Result<()> { + let options = CultMeshRudpDocumentServerOptions { max_remembered_write_bytes: 65, ..Default::default() }; + let sink = Sink::default(); + let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; + let target = server.local_addr()?; + let shared = UdpSocket::bind("127.0.0.1:0")?; + let twin = shared.try_clone()?; + let mut first = client_on(shared, target, 250, 50_000)?; + connect(&mut server, &mut [&mut first])?; + send_reliable(&mut first, &receipt_hello())?; + send_reliable(&mut first, &put("m1", "note:a", vec![1]))?; + assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("m1", "note:a")); + + let mut restarted = client_on(twin, target, 250, 7)?; + connect(&mut server, &mut [&mut restarted])?; + assert_eq!(server.session_count(), 1); + send_reliable(&mut restarted, &receipt_hello())?; + send_reliable(&mut restarted, &put("m1", "note:b", vec![2]))?; + let answers = frames_after(&mut server, &mut restarted, 2)?; + assert_eq!(answers[1], admitted("m1", "note:b"), "same id, other content: a new write on a new session, within a one-entry budget"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); + Ok(()) +} + /// A session that never declared the capability gets no refusal for an unreliable write: it is dropped as before. #[test] fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { From 6eea66c57f18b6e6a753c248f673c03e5ff0c699 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:59:59 +0000 Subject: [PATCH 20/31] cultnet-ts, cultmesh-ts: write receipt client (frames, WriteTracker, replay runner, opt-in writer) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 4 + packages/cultmesh-ts/src/index.ts | 95 +++++++-- .../test/write-receipt-client.test.ts | 139 ++++++++++++++ packages/cultnet-ts/CHANGELOG.md | 1 + packages/cultnet-ts/README.md | 27 +++ packages/cultnet-ts/src/contracts.ts | 43 +++++ packages/cultnet-ts/src/index.ts | 18 ++ packages/cultnet-ts/src/write-tracker.ts | 133 +++++++++++++ .../cultnet-ts/test/write-receipt.test.ts | 181 ++++++++++++++++++ .../tools/replay-write-receipt-vectors.mjs | 116 +++++++++++ 10 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 packages/cultmesh-ts/test/write-receipt-client.test.ts create mode 100644 packages/cultnet-ts/src/write-tracker.ts create mode 100644 packages/cultnet-ts/test/write-receipt.test.ts create mode 100644 packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index d06e753b..f803476a 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ## Unreleased +### Added + +- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, or `session_ended`) when nothing settles it. Without the option, or against a server that did not advertise, nothing changes: no hello, a 500 ms window, and a window with no refusal still reads as published (interim; not admission). `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. + ### Breaking - The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index f065dd83..eab2e6dc 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -20,6 +20,10 @@ import { CultNetRudpSocketTransportConnection, rudpClientBindHost, CultNetSchemaCatalog, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + WriteTracker, + helloAdvertisesWriteReceipts, + resultWindowMs, cultNetMessageInvalidError, cultNetTextError, cultNetWriteNotAuthorizedError, @@ -44,6 +48,8 @@ import { type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, + type WriteSettlement, + type WriteUnconfirmedReason, } from "@gamecult/cultnet-ts"; import { @@ -4189,8 +4195,8 @@ export interface CultMeshRudpDocumentServer { */ export class CultMeshRudpWriteRefusedError extends Error { readonly code: string | undefined; - readonly response: Extract; - constructor(response: Extract) { + readonly response: Extract; + constructor(response: Extract) { super(response.error); this.name = "CultMeshRudpWriteRefusedError"; this.code = response.code ?? undefined; @@ -4198,14 +4204,40 @@ export class CultMeshRudpWriteRefusedError extends Error { } } +/** + * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, or the + * session ended first. Not success and not refusal; a caller that needs admission treats it as not admitted. + */ +export class CultMeshRudpWriteUnconfirmedError extends Error { + readonly reason: WriteUnconfirmedReason; + readonly messageId: string; + constructor(messageId: string, reason: WriteUnconfirmedReason) { + super(`Write ${messageId} was not confirmed: ${reason}.`); + this.name = "CultMeshRudpWriteUnconfirmedError"; + this.reason = reason; + this.messageId = messageId; + } +} + export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions { /** - * How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not - * admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no - * success frame, so the put is reported published once this window passes with no refusal; a success reply - * ends the wait early. Default 500 ms. + * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not + * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the + * write-receipt frames, 500 ms otherwise. Against a server that did not advertise them the put is reported + * published once the window passes with no refusal (interim; the caller must not read it as admission), and + * a success reply ends the wait early. */ resultWindowMs?: number; + /** + * Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` + * before the put. A server that lists both back is receipt-capable: the put then resolves only on its + * `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with + * `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers + * as it always did. + */ + writeReceipts?: boolean; + /** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */ + helloWaitMs?: number; messageId?: string; sourceRuntimeId?: string; sourceAgentId?: string; @@ -6182,17 +6214,42 @@ export class CultMesh { const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { - // A transport ack is not admission: the put completes on its result frame. A refusal rejects with the - // typed error whether it arrives before the ack, after it, or inside the result window. - let refusal: CultMeshRudpWriteRefusedError | undefined; - let succeeded = false; + // A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides + // how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw. + let receiptCapable = false; + let tracker: WriteTracker | undefined; + let settlement: WriteSettlement | undefined; let wake: (() => void) | undefined; + let serverHello: (() => void) | undefined; peer.on("message", (message: CultNetMessage) => { - if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message); - else if ("messageId" in message && message.messageId === messageId) succeeded = true; - else return; - wake?.(); + if (message.schemaVersion === "cultnet.hello.v0") { + receiptCapable = helloAdvertisesWriteReceipts(message); + serverHello?.(); + } else if (!receiptCapable && "messageId" in message && message.messageId === messageId + && message.schemaVersion !== "cultnet.error.v1") { + // A server that did not advertise receipts may echo the id back as its success reply. + tracker?.frame({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: binding.definition.schemaId ?? "", recordKey }, Date.now()); + } else { + tracker?.frame(message, Date.now()); + } }); + peer.on("close", () => tracker?.sessionEnd(Date.now())); + if (options.writeReceipts) { + const answered = new Promise((resolve) => { serverHello = resolve; }); + peer.sendHello({ + schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts", + supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS], + }); + let helloTimer: NodeJS.Timeout | undefined; + await Promise.race([answered, new Promise((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]); + clearTimeout(helloTimer); + } + const writeTracker = new WriteTracker({ + windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable), + onSettle: (_id, result) => { settlement = result; wake?.(); }, + }); + tracker = writeTracker; + writeTracker.send(messageId, Date.now()); peer.send(registry.createRawDocumentPutMessage( binding, messageId, @@ -6206,13 +6263,17 @@ export class CultMesh { }, )); await peer.flush(options.flushTimeoutMs ?? 30_000); - if (!refusal && !succeeded) { + while (!settlement) { + const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await new Promise((resolve) => { - const timer = setTimeout(resolve, options.resultWindowMs ?? 500); + const timer = setTimeout(resolve, remaining); wake = () => { clearTimeout(timer); resolve(); }; }); + writeTracker.tick(Date.now()); } - if (refusal) throw refusal; + if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); + // Interim, for a server that did not advertise receipts: a window with no refusal reads as published. + if (settlement.outcome === "unconfirmed" && receiptCapable) throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts new file mode 100644 index 00000000..ddc3d41e --- /dev/null +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -0,0 +1,139 @@ +// Client side of contracts/cultnet/interop/write-receipt.md in the one-shot writer: with `writeReceipts` the put +// completes only on a write_admitted carrying its id; a refusal, a silent window and a legacy server each end as the +// spec says. Each test serves the client a fake RUDP peer that acknowledges the put and answers as scripted. +import assert from "node:assert/strict"; +import { createSocket, type Socket } from "node:dgram"; +import test from "node:test"; +import { z } from "zod"; +import { defineDocumentType } from "@gamecult/cultcache-ts"; +import { + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + CultNetRudpSession, + decodeRudpPacket, + defineCultNetDocumentBinding, + encodeCultNetMessageForWire, + encodeRudpPacket, + type CultNetMessage, +} from "@gamecult/cultnet-ts"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index"; + +const note = defineDocumentType({ + type: "cultmesh.receipt_note", + schemaId: "cultmesh.receipt_note.v0", + schema: z.object({ noteId: z.string(), text: z.string() }), + name: "noteId", +}); +const binding = defineCultNetDocumentBinding({ definition: note }); +let nextConnection = 0x10206000; + +interface Script { + /** Does the server's hello list both receipt frames? */ + advertise: boolean; + /** What the server answers a put with, `delayMs` after it arrives. */ + answer: "admit" | "refuse" | "wrong-id" | "silence"; + delayMs?: number; +} + +async function fakeServer(connectionId: number, script: Script) { + const socket: Socket = createSocket("udp4"); + await new Promise((resolve) => socket.bind(0, "127.0.0.1", resolve)); + const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); + const timers = new Set(); + const seen: string[] = []; + let closed = false; + const send = (packet: Parameters[0], port: number, address: string) => { + if (!closed) socket.send(encodeRudpPacket(packet), port, address); + }; + const say = (message: CultNetMessage, port: number, address: string) => { + const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() }); + packets.forEach((p) => send(p, port, address)); + }; + socket.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const now = Date.now(); + if (packet.packetType === "connect") { + send(session.acceptConnect(packet, now), remote.port, remote.address); + return; + } + const result = session.receive(packet, now); + if (result.reply) send(result.reply, remote.port, remote.address); + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string }; + seen.push(message.schemaVersion); + if (message.schemaVersion === "cultnet.hello.v0") { + say({ + schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake", + ...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}), + }, remote.port, remote.address); + continue; + } + if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; + const timer = setTimeout(() => { + timers.delete(timer); + if (script.answer === "refuse") { + say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address); + } else { + const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!; + say({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a" }, remote.port, remote.address); + } + }, script.delayMs ?? 20); + timers.add(timer); + } + if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); + }); + return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } }; +} + +const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }; +const publish = (server: { endpoint: string }, options: Record) => + CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options }); + +test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 }); + try { + await publish(server, { writeReceipts: true }); + assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]); + } finally { server.close(); } +}); + +test("a receipt-capable server's error.v1 refuses the put with its code", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" }); + try { + await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) => + error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1"); + } finally { server.close(); } +}); + +test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); + } finally { server.close(); } +}); + +test("a receipt for another id settles nothing", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError); + } finally { server.close(); } +}); + +test("a server whose hello does not advertise keeps the interim 500 ms window-success", async () => { + const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + const started = Date.now(); + try { + await publish(server, { writeReceipts: true }); + assert.ok(Date.now() - started >= 500, "the legacy window ran from the send"); + } finally { server.close(); } +}); + +test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); + try { + await publish(server, { resultWindowMs: 100 }); + assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]); + } finally { server.close(); } +}); diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 739396cd..13dc3dff 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Added +- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`. - `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. diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 1fb37a3f..07996956 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -289,6 +289,33 @@ maps them back to the exact C# union payload shape at the wire boundary. serve and dial commands also exercise schema-v0 over the shared RUDP socket transport and advertise both transport profiles. +## Write receipt replay runner + +`WriteTracker` is the client half of `contracts/cultnet/interop/write-receipt.md`: a pure state machine on an abstract +clock that owns one decision, how each sent write settles. `tools/replay-write-receipt-vectors.mjs` replays +`write-receipt-vectors.json` against it (build first, then `node tools/replay-write-receipt-vectors.mjs`): it prints +`PASS`/`FAIL` per case and exits nonzero on any failure. Rust and C# port the same runner by implementing this interface +and replaying the same file. + +Tracker interface (times are integer milliseconds on the vector's clock; the constructor takes the window): + +| Call | Meaning | +| --- | --- | +| `new WriteTracker({ windowMs, onSettle })` | `windowMs` = 5000 when the server's hello lists both `cultnet.write_admitted.v0` and `cultnet.error.v1`, else 500. `onSettle(id, settlement)` fires once per write. | +| `send(id, atMs)` | A write with that `messageId` was sent. Its window runs from here, not from any transport ack. | +| `frame(message, atMs)` | A decoded frame arrived. `write_admitted` and `error.v1` settle the write they name; `error.v0`, or `error.v1` with nil `messageId`, refuses a write only when exactly one is unanswered; an id not in flight settles nothing. | +| `tick(atMs)` | Advance the clock. Every unanswered write with `atMs - sentAt >= windowMs` settles `unconfirmed(window_elapsed)`. `send`, `frame` and `sessionEnd` tick first. | +| `sessionEnd(atMs)` | Every unanswered write settles `unconfirmed(session_ended)`. | +| `state(id)` | `pending`, or the settlement plus `recorded`: `admitted_late` / `refused_late`, set by the first answer that arrives after `unconfirmed`. A settlement never changes. | + +Runner, per case in `scenarios.cases` (the transport ack is not an input: delivery is not an answer): + +1. The fake server states its capability the only way a real one does, in a hello (`server: "receipt"` lists both frames, `"legacy"` lists none); the window comes from that hello and must equal `constants[case.window]`. +2. Events run in `atMs` order: `send` -> `send(id)`, `frame` -> encode the frame to MessagePack, decode and validate it as the client would, then `frame(message)`, `tick` -> `tick`, `sessionEnd` -> `sessionEnd`, `transportAck` -> nothing. +3. For each id in `expect`, the settlement given to the caller has the expected `outcome` (and `code` or `reason` when given), `state(id).recorded` equals the expected `recorded` (absent when absent), and nothing settled twice or outside `expect`. + +The runner also decodes each `wire[].messagePackHex` and compares the fields and the re-encoded bytes to the vector, and requires every `schemaInvalid[]` frame to be refused by the decoder. `dedupe.cases` and `contentHash` are server vectors and not replayed here. + ## Development ```bash diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index d4bc1b19..6e05c9a5 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -9,6 +9,8 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json"; import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json"; import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json"; import errorSchema from "../contracts/cultnet/cultnet.error.schema.json"; +import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json"; +import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json"; import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json"; import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json"; import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json"; @@ -62,6 +64,8 @@ export type CultNetSchemaVersion = | "cultnet.verify.v0" | "cultnet.login_success.v0" | "cultnet.error.v0" + | "cultnet.error.v1" + | "cultnet.write_admitted.v0" | "cultnet.sample.change_name.v0" | "cultnet.sample.chat.v0" | "cultnet.document_put.v0" @@ -201,6 +205,11 @@ export const CULTNET_ERROR_CODES = [ export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; +/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */ +export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const; + +export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_CODES)[number]; + /** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */ export function cultNetTextError(error: string): CultNetErrorMessage { return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null }; @@ -255,6 +264,34 @@ export interface CultNetErrorMessage { details: CultNetErrorDetails | null; } +/** + * cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a + * connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md). + */ +export interface CultNetErrorV1Message extends Omit { + schemaVersion: "cultnet.error.v1"; + code: CultNetErrorV1Code | null; + messageId: string | null; +} + +/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */ +export interface CultNetWriteAdmittedMessage { + schemaVersion: "cultnet.write_admitted.v0"; + messageId: string; + operation: "put" | "delete"; + schemaId: string; + recordKey: string; +} + +/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */ +export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const; + +/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */ +export function helloAdvertisesWriteReceipts(hello: Pick): boolean { + const listed = hello.supportedMessageVersions ?? []; + return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version)); +} + export interface CultNetSampleChangeNameMessage { schemaVersion: "cultnet.sample.change_name.v0"; name: string; @@ -770,6 +807,8 @@ export interface GhostlightAgentStateDocument { export type CultNetMessage = | CultNetHelloMessage + | CultNetErrorV1Message + | CultNetWriteAdmittedMessage | CultNetLoginMessage | CultNetRegisterMessage | CultNetVerifyMessage @@ -812,6 +851,8 @@ const CULTNET_MESSAGE_SCHEMAS = [ verifySchema, loginSuccessSchema, errorSchema, + errorV1Schema, + writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, @@ -1535,6 +1576,8 @@ export const cultNetSchemas = { verifySchema, loginSuccessSchema, errorSchema, + errorV1Schema, + writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 4fd0014c..a8740cae 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -17,6 +17,12 @@ export { type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, CULTNET_ERROR_CODES, + CULTNET_ERROR_V1_CODES, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + helloAdvertisesWriteReceipts, + type CultNetErrorV1Code, + type CultNetErrorV1Message, + type CultNetWriteAdmittedMessage, cultNetTextError, cultNetMessageInvalidError, cultNetWriteNotAuthorizedError, @@ -249,3 +255,15 @@ export { type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, } from "./idunn-odin-presence-publisher"; + +export { + LEGACY_RESULT_WINDOW_MS, + RECEIPT_RESULT_WINDOW_MS, + WriteTracker, + resultWindowMs, + type WriteRecorded, + type WriteSettlement, + type WriteState, + type WriteTrackerOptions, + type WriteUnconfirmedReason, +} from "./write-tracker"; diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts new file mode 100644 index 00000000..7398afaa --- /dev/null +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -0,0 +1,133 @@ +// The client half of the write receipt (contracts/cultnet/interop/write-receipt.md, "Client rules"): a pure state +// machine with an abstract clock. It owns exactly one decision, how each sent write settles. Transport, timers and +// frame decoding belong to the caller; the replay runner (tools/replay-write-receipt-vectors.mjs) drives this same +// interface from write-receipt-vectors.json. + +import type { CultNetErrorMessage, CultNetErrorV1Message, CultNetMessage } from "./contracts"; + +/** The result window against a server whose hello lists both receipt frames. */ +export const RECEIPT_RESULT_WINDOW_MS = 5000; +/** The result window against a server whose hello does not list them (or that sent none). */ +export const LEGACY_RESULT_WINDOW_MS = 500; + +export function resultWindowMs(serverAdvertisesReceipts: boolean): number { + return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS; +} + +export type WriteUnconfirmedReason = "window_elapsed" | "session_ended"; +/** What a late answer is recorded as on a write that already settled unconfirmed. */ +export type WriteRecorded = "admitted_late" | "refused_late"; + +/** What the caller was told. Settled once; never changed by a later frame. */ +export type WriteSettlement = + | { outcome: "admitted" } + | { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message } + | { outcome: "unconfirmed"; reason: WriteUnconfirmedReason }; + +/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */ +export type WriteState = + | { outcome: "pending"; recorded?: undefined } + | (WriteSettlement & { recorded?: WriteRecorded }); + +export interface WriteTrackerOptions { + /** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */ + windowMs: number; + /** Called once per write, at the moment it settles. */ + onSettle?: (messageId: string, settlement: WriteSettlement) => void; +} + +interface Entry { + sentAtMs: number; + state: WriteState; +} + +export class WriteTracker { + readonly #windowMs: number; + readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void; + readonly #writes = new Map(); + + constructor(options: WriteTrackerOptions) { + this.#windowMs = options.windowMs; + this.#onSettle = options.onSettle; + } + + /** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */ + send(messageId: string, atMs: number): void { + this.tick(atMs); + if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } }); + } + + /** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */ + frame(message: CultNetMessage, atMs: number): void { + this.tick(atMs); + switch (message.schemaVersion) { + case "cultnet.write_admitted.v0": + this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late"); + return; + case "cultnet.error.v1": + if (message.messageId !== null) { + this.#answer(message.messageId, refusalOf(message), "refused_late"); + return; + } + break; + case "cultnet.error.v0": + break; + default: + return; + } + // An error that names no write is attributable only when exactly one write is unanswered. + const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending"); + if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message)); + } + + /** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */ + tick(atMs: number): void { + for (const [messageId, entry] of this.#writes) { + if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) { + this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" }); + } + } + } + + /** The session ended: every unanswered write settles unconfirmed. */ + sessionEnd(atMs: number): void { + this.tick(atMs); + for (const [messageId, entry] of this.#writes) { + if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" }); + } + } + + state(messageId: string): WriteState | undefined { + return this.#writes.get(messageId)?.state; + } + + /** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */ + nextDeadlineMs(atMs: number): number | undefined { + let next: number | undefined; + for (const entry of this.#writes.values()) { + if (entry.state.outcome !== "pending") continue; + const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs); + if (next === undefined || remaining < next) next = remaining; + } + return next; + } + + #answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void { + const entry = this.#writes.get(messageId); + if (!entry) return; // names a write that is not in flight: settles nothing + if (entry.state.outcome === "pending") { + this.#settle(messageId, entry, settlement); + } else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) { + entry.state = { ...entry.state, recorded: late }; + } + } + + #settle(messageId: string, entry: Entry, settlement: WriteSettlement): void { + entry.state = settlement; + this.#onSettle?.(messageId, settlement); + } +} + +function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement { + return { outcome: "refused", code: message.code, error: message.error, response: message }; +} diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts new file mode 100644 index 00000000..cc524bfd --- /dev/null +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +import { + CULTNET_ERROR_CODES, + CULTNET_ERROR_V1_CODES, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + LEGACY_RESULT_WINDOW_MS, + RECEIPT_RESULT_WINDOW_MS, + WriteTracker, + cultNetSchemas, + helloAdvertisesWriteReceipts, + parseCultNetMessage, + resultWindowMs, + type CultNetErrorV1Message, + type CultNetMessage, + type CultNetWriteAdmittedMessage, + type WriteSettlement, +} from "../src"; + +const admitted = (messageId: string): CultNetWriteAdmittedMessage => + ({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" }); +const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message => + ({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId }); +const errorV0 = (): CultNetMessage => + ({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }); + +test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => { + const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null); + assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort()); + for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code }); + assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" })); +}); + +test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => { + assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1"); + assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null); + const { messageId: _omitted, ...withoutId } = refusedV1("w1"); + assert.throws(() => parseCultNetMessage(withoutId)); + assert.throws(() => parseCultNetMessage(refusedV1(""))); +}); + +test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => { + assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false); + assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" })); + assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" })); + assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0"); +}); + +test("write_admitted needs all five keys, a known operation and no error key", () => { + parseCultNetMessage(admitted("w1")); + parseCultNetMessage({ ...admitted("w1"), operation: "delete" }); + assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" })); + assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" })); + const { recordKey: _omitted, ...short } = admitted("w1"); + assert.throws(() => parseCultNetMessage(short)); +}); + +test("a hello is receipt-capable only when it lists both frames", () => { + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true); + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false); + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false); + assert.equal(helloAdvertisesWriteReceipts({}), false); + const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }); + assert.equal(hello.schemaVersion, "cultnet.hello.v0"); +}); + +test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => { + assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS); + assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS); + assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000); + assert.equal(LEGACY_RESULT_WINDOW_MS, 500); +}); + +function tracker(windowMs = 500) { + const told: [string, WriteSettlement][] = []; + return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) }; +} + +test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => { + const { t, told } = tracker(); + t.send("w1", 100); + t.tick(599); + assert.equal(t.state("w1")?.outcome, "pending"); + t.tick(600); + assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]); +}); + +test("tracker: each write's window runs from its own send", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 300); + t.tick(500); + assert.equal(t.state("w1")?.outcome, "unconfirmed"); + assert.equal(t.state("w2")?.outcome, "pending"); +}); + +test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => { + const { t, told } = tracker(); + t.send("w1", 0); + t.frame(admitted("w1"), 500); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" }); + assert.equal(told.length, 1); +}); + +test("tracker: a write settles once; later frames neither change it nor flip a late record", () => { + const { t, told } = tracker(); + t.send("w1", 0); + t.frame(refusedV1("w1"), 10); + t.frame(admitted("w1"), 20); + t.frame(refusedV1("w1", "message_invalid"), 30); + t.sessionEnd(40); + assert.equal(told.length, 1); + assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") }); + t.send("w2", 100); + t.tick(600); + t.frame(refusedV1("w2"), 700); + t.frame(admitted("w2"), 800); + assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late"); +}); + +test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 1); + t.frame(admitted("w1"), 5); + t.frame(refusedV1(null), 6); + assert.equal(t.state("w1")?.outcome, "admitted"); + assert.equal(t.state("w2")?.outcome, "refused"); +}); + +test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => { + const { t } = tracker(); + t.send("w1", 0); + t.tick(500); + t.frame(errorV0(), 501); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); +}); + +test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 0); + t.send("w3", 400); + t.frame(admitted("w2"), 10); + t.sessionEnd(600); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); + assert.equal(t.state("w2")?.outcome, "admitted"); + assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" }); +}); + +test("tracker: re-sending a known id is the same write and does not restart its window", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w1", 400); + t.tick(500); + assert.equal(t.state("w1")?.outcome, "unconfirmed"); +}); + +test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => { + const { t } = tracker(); + assert.equal(t.nextDeadlineMs(0), undefined); + t.send("w1", 0); + t.send("w2", 100); + assert.equal(t.nextDeadlineMs(50), 450); + assert.equal(t.nextDeadlineMs(900), 0); + t.frame(admitted("w1"), 60); + assert.equal(t.nextDeadlineMs(150), 450); + t.frame(admitted("w2"), 160); + assert.equal(t.nextDeadlineMs(170), undefined); +}); + +test("the replay runner passes every vector against this runtime", () => { + const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs"); + const run = spawnSync(process.execPath, [script], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stdout + run.stderr); + assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m); + assert.doesNotMatch(run.stdout, /^FAIL/m); +}); diff --git a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs new file mode 100644 index 00000000..2c4bb403 --- /dev/null +++ b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: every `scenarios.cases` event +// replay through a fake server and the WriteTracker, and every `wire` / `schemaInvalid` frame through the real +// decoder. Prints PASS/FAIL per case; exit 1 on any failure. Runs against the built package (`npm run build` first). +// +// node tools/replay-write-receipt-vectors.mjs [path/to/write-receipt-vectors.json] +// +// The runner interface a port (Rust, C#) must reproduce is documented in README.md, "Write receipt replay runner". + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const here = path.dirname(fileURLToPath(import.meta.url)); +const { decode, encode } = require("@msgpack/msgpack"); +const { + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, LEGACY_RESULT_WINDOW_MS, RECEIPT_RESULT_WINDOW_MS, WriteTracker, + encodeCultNetMessageForWire, helloAdvertisesWriteReceipts, parseCultNetMessage, resultWindowMs, +} = require(path.join(here, "..", "dist", "index.js")); + +const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"); +const vectors = JSON.parse(readFileSync(vectorsPath, "utf8")); + +const results = []; +const check = (name, run) => { + try { + const problems = run(); + results.push({ name, problems }); + } catch (error) { + results.push({ name, problems: [`threw: ${error instanceof Error ? error.message : String(error)}`] }); + } +}; +const bytesOf = (hex) => Uint8Array.from(Buffer.from(hex, "hex")); +const hexOf = (bytes) => Buffer.from(bytes).toString("hex").toUpperCase(); +// A frame reaches the client as bytes: the fake server encodes it, the client decodes and validates it. +const delivered = (frame) => parseCultNetMessage(decode(encode(frame))); + +check("constants", () => [ + ...(vectors.constants.RECEIPT_RESULT_WINDOW_MS === RECEIPT_RESULT_WINDOW_MS ? [] : [`RECEIPT window ${RECEIPT_RESULT_WINDOW_MS} != ${vectors.constants.RECEIPT_RESULT_WINDOW_MS}`]), + ...(vectors.constants.LEGACY_RESULT_WINDOW_MS === LEGACY_RESULT_WINDOW_MS ? [] : [`LEGACY window ${LEGACY_RESULT_WINDOW_MS} != ${vectors.constants.LEGACY_RESULT_WINDOW_MS}`]), +]); + +for (const vector of vectors.wire) { + check(`wire/${vector.name}`, () => { + const problems = []; + const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + if (JSON.stringify(sorted(parsed)) !== JSON.stringify(sorted(vector.message))) problems.push(`decoded ${JSON.stringify(parsed)} != ${JSON.stringify(vector.message)}`); + const reencoded = hexOf(encode(encodeCultNetMessageForWire(parsed))); + if (reencoded !== vector.messagePackHex.toUpperCase()) problems.push(`re-encoded bytes ${reencoded} != captured ${vector.messagePackHex}`); + return problems; + }); +} + +for (const vector of vectors.schemaInvalid) { + check(`schemaInvalid/${vector.name}`, () => { + try { + parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + } catch { + return []; + } + return [`accepted a frame that must be refused (${vector.why})`]; + }); +} + +for (const scenario of vectors.scenarios.cases) { + check(`scenario/${scenario.name}`, () => { + // The fake server states its capability the only way a real one does: in its hello. + const serverHello = { schemaVersion: "cultnet.hello.v0", runtimeId: "fake-server", runtimeKind: "fake", + ...(scenario.server === "receipt" ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}) }; + const windowMs = resultWindowMs(helloAdvertisesWriteReceipts(serverHello)); + const problems = []; + if (windowMs !== vectors.constants[scenario.window]) problems.push(`window ${windowMs} != ${scenario.window}`); + + const told = new Map(); // what the caller was told, at settle time + const tracker = new WriteTracker({ windowMs, onSettle: (id, settlement) => { + if (told.has(id)) problems.push(`${id} settled twice`); + told.set(id, settlement); + } }); + for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) { + if ("send" in event) tracker.send(event.send, event.atMs); + else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs); + else if ("tick" in event) tracker.tick(event.atMs); + else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs); + // transportAck: delivery is not an answer; the tracker has no such input and must not need one. + } + for (const [id, want] of Object.entries(scenario.expect)) { + const got = told.get(id); + if (!got) { problems.push(`${id}: never settled, wanted ${want.outcome}`); continue; } + if (got.outcome !== want.outcome) problems.push(`${id}: told ${got.outcome}, wanted ${want.outcome}`); + if (want.code !== undefined && got.code !== want.code) problems.push(`${id}: code ${got.code}, wanted ${want.code}`); + if (want.reason !== undefined && got.reason !== want.reason) problems.push(`${id}: reason ${got.reason}, wanted ${want.reason}`); + const recorded = tracker.state(id)?.recorded; + if (recorded !== want.recorded) problems.push(`${id}: recorded ${recorded}, wanted ${want.recorded}`); + } + for (const id of told.keys()) if (!(id in scenario.expect)) problems.push(`${id}: settled but the scenario expects nothing for it`); + return problems; + }); +} + +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])])); + return value; +} + +let failed = 0; +for (const { name, problems } of results) { + if (problems.length === 0) { console.log(`PASS ${name}`); continue; } + failed++; + console.log(`FAIL ${name}`); + for (const problem of problems) console.log(` ${problem}`); +} +console.log(`${results.length - failed}/${results.length} passed`); +process.exit(failed === 0 ? 0 : 1); From a67faad27c912130dc355afb8c221bbc9b5cc5a1 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:04:57 +0000 Subject: [PATCH 21/31] cultnet-ts: tests for two tracker mutants (send expires other windows; non-answer frames settle nothing) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-ts/test/write-receipt.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index cc524bfd..66b80601 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -159,6 +159,21 @@ test("tracker: re-sending a known id is the same write and does not restart its assert.equal(t.state("w1")?.outcome, "unconfirmed"); }); +test("tracker: a send past another write's window settles that write at once", () => { + const { t, told } = tracker(); + t.send("w1", 0); + t.send("w2", 600); + assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]); +}); + +test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => { + const { t } = tracker(); + t.send("w1", 0); + t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1); + t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2); + assert.equal(t.state("w1")?.outcome, "pending"); +}); + test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => { const { t } = tracker(); assert.equal(t.nextDeadlineMs(0), undefined); From 293b4e83bc3e379a0f52c8bfcd8025d374851b03 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:40:52 +0000 Subject: [PATCH 22/31] cultmesh-ts, cultnet-ts: an unanswered put is unconfirmed on every server, session_ended before a result is an error, a failed local send is its own reason Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 2 +- packages/cultmesh-ts/src/index.ts | 44 +++++++++--------- .../test/client-write-completion.test.ts | 11 +++-- packages/cultmesh-ts/test/cultmesh.test.ts | 45 ++++++++++--------- .../test/write-receipt-client.test.ts | 35 ++++++++++++--- packages/cultnet-ts/src/write-tracker.ts | 13 +++++- .../cultnet-ts/test/write-receipt.test.ts | 26 +++++++++++ 7 files changed, 124 insertions(+), 52 deletions(-) diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index f803476a..e7b432a5 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### Added -- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, or `session_ended`) when nothing settles it. Without the option, or against a server that did not advertise, nothing changes: no hello, a 500 ms window, and a window with no refusal still reads as published (interim; not admission). `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. +- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). The legacy success reply (the put's id echoed) still admits it. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. ### Breaking diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index eab2e6dc..43a44c9b 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4205,8 +4205,8 @@ export class CultMeshRudpWriteRefusedError extends Error { } /** - * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, or the - * session ended first. Not success and not refusal; a caller that needs admission treats it as not admitted. + * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the + * session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted. */ export class CultMeshRudpWriteUnconfirmedError extends Error { readonly reason: WriteUnconfirmedReason; @@ -4223,9 +4223,9 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti /** * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the - * write-receipt frames, 500 ms otherwise. Against a server that did not advertise them the put is reported - * published once the window passes with no refusal (interim; the caller must not read it as admission), and - * a success reply ends the wait early. + * write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never + * published, whatever the server advertised; a legacy server's success reply (the put's id echoed back) ends + * the wait early as admitted. */ resultWindowMs?: number; /** @@ -6250,19 +6250,24 @@ export class CultMesh { }); tracker = writeTracker; writeTracker.send(messageId, Date.now()); - peer.send(registry.createRawDocumentPutMessage( - binding, - messageId, - recordKey, - value, - { - sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, - }, - )); - await peer.flush(options.flushTimeoutMs ?? 30_000); + try { + peer.send(registry.createRawDocumentPutMessage( + binding, + messageId, + recordKey, + value, + { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, + sourceAgentId: options.sourceAgentId, + sourceRole: options.sourceRole, + tags: options.tags, + }, + )); + await peer.flush(options.flushTimeoutMs ?? 30_000); + } catch { + // Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal. + writeTracker.sendFailed(messageId, Date.now()); + } while (!settlement) { const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await new Promise((resolve) => { @@ -6272,8 +6277,7 @@ export class CultMesh { writeTracker.tick(Date.now()); } if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); - // Interim, for a server that did not advertise receipts: a window with no refusal reads as published. - if (settlement.outcome === "unconfirmed" && receiptCapable) throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); + if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/client-write-completion.test.ts b/packages/cultmesh-ts/test/client-write-completion.test.ts index 8dd122ed..5c4a1eaa 100644 --- a/packages/cultmesh-ts/test/client-write-completion.test.ts +++ b/packages/cultmesh-ts/test/client-write-completion.test.ts @@ -16,7 +16,7 @@ import { encodeRudpPacket, } from "@gamecult/cultnet-ts"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; -import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError } from "../src/index"; +import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index"; const isTheRefusal = (error: unknown): boolean => error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized"; @@ -100,7 +100,7 @@ for (const order of orders) { }); } -test("publishRudpDocumentOnce still publishes a put the server admitted", async () => { +test("a put the server applied but did not answer is unconfirmed, never published", async () => { const connectionId = nextConnection++; const puts: string[] = []; const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, { @@ -111,7 +111,10 @@ test("publishRudpDocumentOnce still publishes a put the server admitted", async }); await server.start(); try { - await CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }); - assert.deepEqual(puts, ["tests:completion:a"]); + await assert.rejects( + CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }), + (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", + ); + assert.deepEqual(puts, ["tests:completion:a"], "the server did apply it; this server just does not answer"); } finally { server.close(); } }); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 90ff38f9..68429969 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -21,7 +21,7 @@ import { type CultNetMessage, type CultNetSnapshotResponseRawMessage, } from "@gamecult/cultnet-ts"; -import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index"; +import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError, CultMeshRudpWriteUnconfirmedError } from "../src/index"; // A record's payload as the bytes a peer would put on the wire. const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value)); @@ -2968,25 +2968,30 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () try { await server.start(); - await CultMesh.publishRudpDocumentOnce( - "cultmesh-ts-muninn", - connectionId, - `rudp://127.0.0.1:${server.bind.port}`, - defineCultNetDocumentBinding({ definition: noteDocument }), - "note:odin", - { - noteId: "note:odin", - body: "pay respects once", - }, - { - resendDelayMs: 25, - resendPollMs: 5, - maxFragmentBytes: 1024, - maxPendingReliablePackets: 16, - connectTimeoutMs: 1_000, - flushTimeoutMs: 25, - sourceRole: "test-provider", - }, + // This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did. + await assert.rejects( + CultMesh.publishRudpDocumentOnce( + "cultmesh-ts-muninn", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + defineCultNetDocumentBinding({ definition: noteDocument }), + "note:odin", + { + noteId: "note:odin", + body: "pay respects once", + }, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + flushTimeoutMs: 25, + resultWindowMs: 100, + sourceRole: "test-provider", + }, + ), + (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", ); await waitFor(() => received !== undefined, "published RUDP document"); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index ddc3d41e..e6da5bf2 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -31,8 +31,10 @@ interface Script { /** Does the server's hello list both receipt frames? */ advertise: boolean; /** What the server answers a put with, `delayMs` after it arrives. */ - answer: "admit" | "refuse" | "wrong-id" | "silence"; + answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye"; delayMs?: number; + /** Acknowledge the reliable packets it receives? Default true. */ + ack?: boolean; } async function fakeServer(connectionId: number, script: Script) { @@ -72,7 +74,9 @@ async function fakeServer(connectionId: number, script: Script) { if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; const timer = setTimeout(() => { timers.delete(timer); - if (script.answer === "refuse") { + if (script.answer === "goodbye") { + send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address); + } else if (script.answer === "refuse") { say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address); } else { const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!; @@ -81,7 +85,7 @@ async function fakeServer(connectionId: number, script: Script) { }, script.delayMs ?? 20); timers.add(timer); } - if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); + if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); }); return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } }; } @@ -121,19 +125,38 @@ test("a receipt for another id settles nothing", async () => { } finally { server.close(); } }); -test("a server whose hello does not advertise keeps the interim 500 ms window-success", async () => { +test("a server whose hello does not advertise leaves a silent put unconfirmed after the 500 ms window, never published", async () => { const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); const started = Date.now(); try { - await publish(server, { writeReceipts: true }); + await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); assert.ok(Date.now() - started >= 500, "the legacy window ran from the send"); } finally { server.close(); } }); +test("a session that ends before any result leaves the put unconfirmed (session_ended), against a legacy server too", async () => { + for (const advertise of [false, true]) { + const server = await fakeServer(nextConnection, { advertise, answer: "goodbye" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 3_000 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "session_ended"); + } finally { server.close(); } + } +}); + +test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); + try { + await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + } finally { server.close(); } +}); + test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); try { - await publish(server, { resultWindowMs: 100 }); + await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError); assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]); } finally { server.close(); } }); diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts index 7398afaa..11229232 100644 --- a/packages/cultnet-ts/src/write-tracker.ts +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -14,7 +14,11 @@ export function resultWindowMs(serverAdvertisesReceipts: boolean): number { return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS; } -export type WriteUnconfirmedReason = "window_elapsed" | "session_ended"; +/** + * Why nothing settled a write: its window ran out, the session ended first, or the client's own send of it failed (it may or + * may not have left the machine, and is never a refusal). + */ +export type WriteUnconfirmedReason = "window_elapsed" | "session_ended" | "local_send_failed"; /** What a late answer is recorded as on a write that already settled unconfirmed. */ export type WriteRecorded = "admitted_late" | "refused_late"; @@ -97,6 +101,13 @@ export class WriteTracker { } } + /** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */ + sendFailed(messageId: string, atMs: number): void { + this.tick(atMs); + const entry = this.#writes.get(messageId); + if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" }); + } + state(messageId: string): WriteState | undefined { return this.#writes.get(messageId)?.state; } diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 66b80601..31a1c388 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -194,3 +194,29 @@ test("the replay runner passes every vector against this runtime", () => { assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m); assert.doesNotMatch(run.stdout, /^FAIL/m); }); + +test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => { + const settled: [string, WriteSettlement][] = []; + const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) }); + tracker.send("w1", 0); + tracker.send("w2", 0); + tracker.sendFailed("w1", 10); + assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]); + assert.equal(tracker.state("w2")?.outcome, "pending"); + tracker.frame(admitted("w1"), 20); + assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" }); + tracker.sendFailed("w2", 30); + tracker.sendFailed("w2", 31); + assert.equal(settled.length, 2, "a write settles once"); + tracker.sendFailed("unknown", 40); + assert.equal(tracker.state("unknown"), undefined); +}); + +test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => { + const tracker = new WriteTracker({ windowMs: 1000 }); + tracker.send("w1", 0); + tracker.send("w2", 500); + tracker.sendFailed("w2", 1200); + assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); + assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" }); +}); From af1ac72359187291489154f156b3a9c5bd7580c9 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:05:42 +0000 Subject: [PATCH 23/31] cultmesh-ts, cultnet-ts: only write_admitted admits a put (no echo admission); the send is bounded by the result window and a timeout is local_send_failed Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 2 +- packages/cultmesh-ts/src/index.ts | 13 +++++----- .../test/write-receipt-client.test.ts | 24 +++++++++++++++++-- packages/cultnet-ts/src/write-tracker.ts | 2 +- .../cultnet-ts/test/write-receipt.test.ts | 7 ++++++ 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index e7b432a5..f4612f2e 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### Added -- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). The legacy success reply (the put's id echoed) still admits it. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. +- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. ### Breaking diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 43a44c9b..be053f53 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4224,8 +4224,9 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the * write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never - * published, whatever the server advertised; a legacy server's success reply (the put's id echoed back) ends - * the wait early as admitted. + * published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also + * bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is + * unconfirmed as `local_send_failed`. */ resultWindowMs?: number; /** @@ -6225,10 +6226,6 @@ export class CultMesh { if (message.schemaVersion === "cultnet.hello.v0") { receiptCapable = helloAdvertisesWriteReceipts(message); serverHello?.(); - } else if (!receiptCapable && "messageId" in message && message.messageId === messageId - && message.schemaVersion !== "cultnet.error.v1") { - // A server that did not advertise receipts may echo the id back as its success reply. - tracker?.frame({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: binding.definition.schemaId ?? "", recordKey }, Date.now()); } else { tracker?.frame(message, Date.now()); } @@ -6263,7 +6260,9 @@ export class CultMesh { tags: options.tags, }, )); - await peer.flush(options.flushTimeoutMs ?? 30_000); + // The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure. + const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; + await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1))); } catch { // Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal. writeTracker.sendFailed(messageId, Date.now()); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index e6da5bf2..cf07138b 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -31,7 +31,7 @@ interface Script { /** Does the server's hello list both receipt frames? */ advertise: boolean; /** What the server answers a put with, `delayMs` after it arrives. */ - answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye"; + answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo"; delayMs?: number; /** Acknowledge the reliable packets it receives? Default true. */ ack?: boolean; @@ -74,7 +74,9 @@ async function fakeServer(connectionId: number, script: Script) { if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; const timer = setTimeout(() => { timers.delete(timer); - if (script.answer === "goodbye") { + if (script.answer === "echo") { + say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: message.messageId!, document: { schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } } as unknown as CultNetMessage, remote.port, remote.address); + } else if (script.answer === "goodbye") { send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address); } else if (script.answer === "refuse") { say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address); @@ -153,6 +155,24 @@ test("a put whose send is never acknowledged is unconfirmed as local_send_failed } finally { server.close(); } }); +test("the flush is bounded by the result window: a send nobody acknowledges is local_send_failed at the window, not at the flush timeout", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); + const started = Date.now(); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300, flushTimeoutMs: 20_000 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + assert.ok(Date.now() - started < 3_000, "it did not wait out the 20 s flush timeout"); + } finally { server.close(); } +}); + +test("a server that does not advertise receipts and echoes the put's id back has not admitted it", async () => { + const server = await fakeServer(nextConnection, { advertise: false, answer: "echo" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); + } finally { server.close(); } +}); + test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); try { diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts index 11229232..2204d371 100644 --- a/packages/cultnet-ts/src/write-tracker.ts +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -103,9 +103,9 @@ export class WriteTracker { /** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */ sendFailed(messageId: string, atMs: number): void { - this.tick(atMs); const entry = this.#writes.get(messageId); if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" }); + this.tick(atMs); } state(messageId: string): WriteState | undefined { diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 31a1c388..83c31411 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -220,3 +220,10 @@ test("a failed local send advances the clock: other writes whose windows have ru assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" }); }); + +test("a write whose own send failed settles local_send_failed even when its window has also run", () => { + const tracker = new WriteTracker({ windowMs: 1000 }); + tracker.send("w1", 0); + tracker.sendFailed("w1", 1000); + assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" }); +}); From c89c4ee39ab552b2a44a3093600a33c3164d89a0 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:07:54 +0000 Subject: [PATCH 24/31] cultmesh-ts: test kills the flush-cap mutant Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/test/write-receipt-client.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index cf07138b..19f27a3c 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -149,9 +149,11 @@ test("a session that ends before any result leaves the put unconfirmed (session_ test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => { const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); + const started = Date.now(); try { await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + assert.ok(Date.now() - started < 2_500, "flushTimeoutMs bounds the send inside the 5 s window"); } finally { server.close(); } }); From 0caef4d564e695033b179d171d6bf1b10a25c5d4 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:38:47 +0000 Subject: [PATCH 25/31] WIP (unverified): publishRudpDocumentOnce sendOnly + presence publisher send-only; tests written, cultmesh-ts receipt-client run hung Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 1 + packages/cultmesh-ts/src/index.ts | 46 ++++++++--- .../test/write-receipt-client.test.ts | 31 +++++++ .../src/idunn-odin-presence-publisher.ts | 30 ++++--- .../idunn-odin-presence-send-only.test.ts | 82 +++++++++++++++++++ 5 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index f4612f2e..5df85ad3 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### Breaking +- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default. - The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. - `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 `` and yield `CultMeshCatalogValue`: 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. diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index be053f53..38c0b34a 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4239,6 +4239,14 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti writeReceipts?: boolean; /** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */ helloWaitMs?: number; + /** + * Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the + * transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError` + * (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or + * refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with + * `writeReceipts`. + */ + sendOnly?: boolean; messageId?: string; sourceRuntimeId?: string; sourceAgentId?: string; @@ -6208,13 +6216,35 @@ export class CultMesh { recordKey: string, value: CultCacheDocumentValue, options: CultMeshRudpDocumentPublishOptions = {}, - ): Promise { + ): Promise<"admitted" | "sent"> { requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(recordKey, "recordKey"); + if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined."); const registry = new CultNetDocumentRegistry([binding]); const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; + const putMessage = () => registry.createRawDocumentPutMessage( + binding, + messageId, + recordKey, + value, + { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, + sourceAgentId: options.sourceAgentId, + sourceRole: options.sourceRole, + tags: options.tags, + }, + ); const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { + if (options.sendOnly) { + try { + peer.send(putMessage()); + await peer.flush(options.flushTimeoutMs ?? 30_000); + } catch { + throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed"); + } + return "sent"; + } // A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides // how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw. let receiptCapable = false; @@ -6248,18 +6278,7 @@ export class CultMesh { tracker = writeTracker; writeTracker.send(messageId, Date.now()); try { - peer.send(registry.createRawDocumentPutMessage( - binding, - messageId, - recordKey, - value, - { - sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, - }, - )); + peer.send(putMessage()); // The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure. const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1))); @@ -6277,6 +6296,7 @@ export class CultMesh { } if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); + return "admitted"; } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index 19f27a3c..f1f05410 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -175,6 +175,37 @@ test("a server that does not advertise receipts and echoes the put's id back has } finally { server.close(); } }); +test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => { + const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + try { + await assert.rejects(publish(server, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); + const outcome = await publish(server, { sendOnly: true }); + assert.equal(outcome, "sent"); + assert.notEqual(outcome, "admitted"); + assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0", "cultnet.document_put_raw.v0"], "sendOnly sends no hello"); + } finally { server.close(); } +}); + +test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => { + const admitting = await fakeServer(nextConnection, { advertise: true, answer: "admit" }); + try { + assert.equal(await publish(admitting, { writeReceipts: true }), "admitted"); + } finally { admitting.close(); } + const refusing = await fakeServer(nextConnection, { advertise: true, answer: "refuse", delayMs: 1 }); + try { + assert.equal(await publish(refusing, { sendOnly: true }), "sent", "a refusal is not awaited: sendOnly says only that the put was sent"); + } finally { refusing.close(); } +}); + +test("sendOnly fails local_send_failed when the send is never acknowledged, and cannot be combined with writeReceipts", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", ack: false }); + try { + await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + await assert.rejects(publish(server, { sendOnly: true, writeReceipts: true }), TypeError); + } finally { server.close(); } +}); + test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); try { diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 209a2881..a5994f56 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher( const publish = async (detail: string) => { const signed = signer.sign(detail); messageSequence += 1; - await publishDocument(endpoint, connectionId, { + await sendPresenceDocument(endpoint, connectionId, { schemaVersion: "cultnet.document_put_raw.v0", messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, document: signed.document, @@ -86,11 +86,19 @@ export function createIdunnRuntimePresencePublisher( }; } -async function publishDocument( +/** + * Sends one presence put and says only that it was sent (write-receipt.md: the explicit send-only mode). Odin does not + * answer a write with a receipt yet, so no frame, whatever id it carries, is admission; this never claims it. It returns + * once the transport has acknowledged the put, rejects `IdunnPresenceRefusedError` if a refusal arrives by then, and + * rejects if the put is never acknowledged. + * + * @internal exported for tests + */ +export async function sendPresenceDocument( endpoint: { host: string; port: number }, connectionId: number, message: Record, -): Promise { +): Promise<"sent"> { const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); await new Promise((resolve, reject) => { socket.once("error", reject); @@ -107,26 +115,24 @@ async function publishDocument( const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); for (const packet of packets) await sendPacket(socket, endpoint, packet); - // The transport ack is not admission: the put completes on Odin's result frame. A refusal rejects whether - // it arrives before the ack, after it, or inside the result window; a success reply ends the wait early. - let succeeded = false; + // The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything. const onFrame = (frame: { channelId: string; payload: Uint8Array }) => { if (frame.channelId !== "schema") return; const response = parseCultNetMessage(decode(frame.payload)); if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response); - succeeded = true; + // An error.v1 refuses this put only when it names it (or names nothing). + if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) { + throw new IdunnPresenceRefusedError(response); + } }; await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame); - if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true); + return "sent"; } finally { receiver.close(); socket.close(); } } -/** How long after the transport ack Odin's refusal may still arrive; a success reply ends the wait early. */ -const RESULT_WINDOW_MS = 500; - /** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */ export class IdunnPresenceRefusedError extends Error { readonly code: string | undefined; @@ -200,7 +206,6 @@ async function receiveUntil( predicate: (packet: CultNetRudpPacket) => boolean, timeoutMs: number, onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, - returnOnTimeout = false, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -215,7 +220,6 @@ async function receiveUntil( } for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); } - if (returnOnTimeout) return; throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); } diff --git a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts new file mode 100644 index 00000000..10a249f1 --- /dev/null +++ b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts @@ -0,0 +1,82 @@ +// The presence publisher is send-only (write-receipt.md): Odin answers no write with a receipt yet, so no frame, whatever +// id it carries, is admission. Each test serves it a fake Odin that acknowledges the put and answers as scripted. +import assert from "node:assert/strict"; +import { createSocket, type Socket } from "node:dgram"; +import test from "node:test"; +import { encode, decode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, type CultNetMessage } from "../src/contracts"; +import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-odin-presence-publisher"; +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; + +interface Script { + answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other"; + /** Acknowledge the put? Default true. */ + ack?: boolean; +} + +async function fakeOdin(connectionId: number, script: Script) { + const socket: Socket = createSocket("udp4"); + await new Promise((resolve) => socket.bind(0, "127.0.0.1", resolve)); + const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); + const send = (packet: Parameters[0], port: number, address: string) => { + try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ } + }; + const say = (message: CultNetMessage, port: number, address: string) => { + const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() }); + packets.forEach((p) => send(p, port, address)); + }; + socket.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const now = Date.now(); + if (packet.packetType === "connect") { + send(session.acceptConnect(packet, now), remote.port, remote.address); + return; + } + const result = session.receive(packet, now); + if (result.reply) send(result.reply, remote.port, remote.address); + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const put = decode(frame.payload) as { messageId: string; document: unknown }; + if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address); + else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address); + else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address); + else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address); + } + if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); + }); + return { port: socket.address().port, close: () => socket.close() }; +} + +let nextConnection = 0x0d1d1000; +const put = (id: string) => ({ schemaVersion: "cultnet.document_put_raw.v0", messageId: id, document: { schemaId: "s", recordKey: "k", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } }); +const sendTo = (server: { port: number }, id: string, connectionId: number) => + sendPresenceDocument({ host: "127.0.0.1", port: server.port }, connectionId, put(id)); + +for (const answer of ["silence", "echo", "admitted", "refuse-v1-other"] as const) { + test(`a ${answer} Odin yields sent, never admitted: no frame carrying the put's id is success`, async () => { + const connectionId = nextConnection++; + const server = await fakeOdin(connectionId, { answer }); + try { + const outcome = await sendTo(server, "presence-1", connectionId); + assert.equal(outcome, "sent"); + assert.notEqual(outcome as string, "admitted"); + } finally { server.close(); } + }); +} + +test("a refusal that arrives by the transport ack rejects the put", async () => { + const connectionId = nextConnection++; + const server = await fakeOdin(connectionId, { answer: "refuse-v0" }); + try { + await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError); + } finally { server.close(); } +}); + +test("a put the transport never acknowledges is an error, not sent", async () => { + const connectionId = nextConnection++; + const server = await fakeOdin(connectionId, { answer: "silence", ack: false }); + try { + await assert.rejects(sendTo(server, "presence-1", connectionId), /Timed out/); + } finally { server.close(); } +}); From 206dbe142bf4c448c186d8f39c6b4d9d5b30c011 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:24:52 +0000 Subject: [PATCH 26/31] cultmesh-ts: sendOnly tests use one fake server per publish (the reused one threw on the second connect and hung the run); spec names send-only and the presence publisher The hang was the fake server, not the writer: it is bound to one connection id, and two publishes in one test sent a second connect it rejected from inside the socket handler. The unacknowledged-send test also scripted an admit, whose reply piggybacks the put's ack. Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- .../test/write-receipt-client.test.ts | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index ce708ca6..f927fc00 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -61,7 +61,7 @@ A write settles as exactly one of: ## Changes to what #55 to #58 shipped -Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so; the C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. +Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`; the Idunn presence publisher uses it. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. ## Vectors diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index f1f05410..797df1cd 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -176,14 +176,17 @@ test("a server that does not advertise receipts and echoes the put's id back has }); test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => { - const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); try { - await assert.rejects(publish(server, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); - const outcome = await publish(server, { sendOnly: true }); + await assert.rejects(publish(dflt, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); + } finally { dflt.close(); } + const sendOnly = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + try { + const outcome = await publish(sendOnly, { sendOnly: true }); assert.equal(outcome, "sent"); assert.notEqual(outcome, "admitted"); - assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0", "cultnet.document_put_raw.v0"], "sendOnly sends no hello"); - } finally { server.close(); } + assert.deepEqual(sendOnly.seen, ["cultnet.document_put_raw.v0"], "sendOnly sends no hello"); + } finally { sendOnly.close(); } }); test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => { @@ -197,15 +200,18 @@ test("only a write_admitted resolves admitted, and sendOnly does not wait for or } finally { refusing.close(); } }); -test("sendOnly fails local_send_failed when the send is never acknowledged, and cannot be combined with writeReceipts", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", ack: false }); +test("sendOnly fails local_send_failed when the send is never acknowledged", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); try { await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); - await assert.rejects(publish(server, { sendOnly: true, writeReceipts: true }), TypeError); } finally { server.close(); } }); +test("sendOnly cannot be combined with writeReceipts", async () => { + await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError); +}); + test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); try { From 89ff3d2cbd0e136f8b75faa3f4a408c978d4d934 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:26:25 +0000 Subject: [PATCH 27/31] cultnet-ts test: the Odin presence publisher is send-only, so a refusal after the transport ack is not awaited Co-Authored-By: Claude Sonnet 5.5 --- .../test/idunn-runtime-authority.test.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 2e05bbf4..50236782 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -345,18 +345,27 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte assert.equal(peer.failure, undefined, peer.failure?.message); }); -for (const order of ["ack-then-refusal", "refusal-then-ack"] as const) { - test(`the Odin publisher does not complete on the ack of a refused presence (${order})`, async (context) => { - const endpoint = expectedFacts("service").odinEndpoint!; - const peer = await startOdinPeer(context, endpoint); - peer.reject = true; - peer.order = order; - const signer = signerFor(context, "service"); - signer.reportHealth("active"); - const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); - }); -} +test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + peer.reject = true; + peer.order = "refusal-then-ack"; + const signer = signerFor(context, "service"); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); +}); + +test("the Odin publisher is send-only: a refusal that follows the transport ack is not awaited", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + peer.reject = true; + peer.order = "ack-then-refusal"; + const signer = signerFor(context, "service"); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("denied presence"); +}); test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { const endpoint = expectedFacts("service").odinEndpoint!; From 0fbf2baea6e146a544059abb0d6bf9ed40b2e3de Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:32:49 +0000 Subject: [PATCH 28/31] cultnet-ts, cultmesh-ts: Heimdall's five Lows on send-only (spec wording, error.v1 ack-time refusal tests, build errors keep their cause, presence changelog, ack-cut anchors) Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- docs/cultnet-rudp-ack-cut.md | 4 ++-- packages/cultmesh-ts/src/index.ts | 4 +++- .../cultmesh-ts/test/write-receipt-client.test.ts | 11 +++++++++++ packages/cultnet-ts/CHANGELOG.md | 1 + .../test/idunn-odin-presence-send-only.test.ts | 14 +++++++++++++- 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index f927fc00..1971b3b9 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -61,7 +61,7 @@ A write settles as exactly one of: ## Changes to what #55 to #58 shipped -Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`; the Idunn presence publisher uses it. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. +Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. ## Vectors diff --git a/docs/cultnet-rudp-ack-cut.md b/docs/cultnet-rudp-ack-cut.md index 1b519484..47008c81 100644 --- a/docs/cultnet-rudp-ack-cut.md +++ b/docs/cultnet-rudp-ack-cut.md @@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged. | `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` | | `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) | | `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it | -| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | -| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | +| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | +| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | | `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published | | `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) | | C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None | diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 38c0b34a..0bf8ceae 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6237,8 +6237,10 @@ export class CultMesh { const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { if (options.sendOnly) { + // Built outside the try: a message that cannot be serialized is the caller's error, with its cause, not a send failure. + const put = putMessage(); try { - peer.send(putMessage()); + peer.send(put); await peer.flush(options.flushTimeoutMs ?? 30_000); } catch { throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed"); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index 797df1cd..b5f67de6 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -208,6 +208,17 @@ test("sendOnly fails local_send_failed when the send is never acknowledged", asy } finally { server.close(); } }); +test("sendOnly keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => { + const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + try { + await assert.rejects( + CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, sendOnly: true }), + (error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error, + ); + assert.deepEqual(server.seen, [], "nothing was sent"); + } finally { server.close(); } +}); + test("sendOnly cannot be combined with writeReceipts", async () => { await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError); }); diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 13dc3dff..be6e2d98 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Breaking +- The Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success. - The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged. - `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types. diff --git a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts index 10a249f1..fff8e2d5 100644 --- a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts +++ b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts @@ -10,7 +10,7 @@ import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-od import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; interface Script { - answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other"; + answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none"; /** Acknowledge the put? Default true. */ ack?: boolean; } @@ -42,6 +42,8 @@ async function fakeOdin(connectionId: number, script: Script) { else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address); else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address); else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address); + else if (script.answer === "refuse-v1-same") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: put.messageId }, remote.port, remote.address); + else if (script.answer === "refuse-v1-none") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: null }, remote.port, remote.address); } if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); }); @@ -73,6 +75,16 @@ test("a refusal that arrives by the transport ack rejects the put", async () => } finally { server.close(); } }); +for (const answer of ["refuse-v1-same", "refuse-v1-none"] as const) { + test(`an error.v1 refusal that ${answer === "refuse-v1-same" ? "names this put" : "names no put"} rejects the put`, async () => { + const connectionId = nextConnection++; + const server = await fakeOdin(connectionId, { answer }); + try { + await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError); + } finally { server.close(); } + }); +} + test("a put the transport never acknowledges is an error, not sent", async () => { const connectionId = nextConnection++; const server = await fakeOdin(connectionId, { answer: "silence", ack: false }); From dc1ae51e928b6b1d9c03a13a379849f8e54d5786 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:37:08 +0000 Subject: [PATCH 29/31] cultmesh-ts: publishRudpDocumentOnce builds the put before connecting, so a put that cannot be built keeps its cause on both paths Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/src/index.ts | 7 +++---- .../cultmesh-ts/test/write-receipt-client.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 0bf8ceae..2f734cfd 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6222,7 +6222,8 @@ export class CultMesh { if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined."); const registry = new CultNetDocumentRegistry([binding]); const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; - const putMessage = () => registry.createRawDocumentPutMessage( + // Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause. + const put = registry.createRawDocumentPutMessage( binding, messageId, recordKey, @@ -6237,8 +6238,6 @@ export class CultMesh { const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { if (options.sendOnly) { - // Built outside the try: a message that cannot be serialized is the caller's error, with its cause, not a send failure. - const put = putMessage(); try { peer.send(put); await peer.flush(options.flushTimeoutMs ?? 30_000); @@ -6280,7 +6279,7 @@ export class CultMesh { tracker = writeTracker; writeTracker.send(messageId, Date.now()); try { - peer.send(putMessage()); + peer.send(put); // The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure. const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1))); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index b5f67de6..072b89a3 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -219,6 +219,17 @@ test("sendOnly keeps the cause of a put that cannot be built: it is not reported } finally { server.close(); } }); +test("the receipt path keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "admit" }); + try { + await assert.rejects( + CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, writeReceipts: true }), + (error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error, + ); + assert.ok(!server.seen.includes("cultnet.document_put_raw.v0"), "no put was sent"); + } finally { server.close(); } +}); + test("sendOnly cannot be combined with writeReceipts", async () => { await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError); }); From 90d252735f422cbc0ae3c29000a005a5adb10876 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:38:43 +0000 Subject: [PATCH 30/31] cultmesh-rs: a refused reliable-sequenced write is received and acknowledged first, and a refused write counts as session activity Refusing before session.receive left the packet unacknowledged: the client resent it forever and every later ordered write stalled. The test now uses the same peer and proves a later ordered write is admitted. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 26 ++++++++++---- .../cultmesh-rs/tests/rudp_document_server.rs | 36 ++++++++++++++++--- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 4df5cd31..f97b2578 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -753,15 +753,19 @@ where self.packets_dropped += 1; return Ok(CultMeshRudpPollOutcome::Handled); } - if packet.packet_type == CultNetRudpPacketType::Data - && packet.channel_id == "schema" - && (!packet.reliable || (packet.sequenced && self.receipts(key))) - { - // A receipt session's write that could be lost without trace (unreliable, or on a sequenced channel - // that drops superseded packets) cannot be answered once: it is refused, never applied. Anything - // else unreliable is dropped as before. + let is_schema_data = packet.packet_type == CultNetRudpPacketType::Data && packet.channel_id == "schema"; + if is_schema_data && !packet.reliable { + // An unreliable write cannot be answered once: a receipt session is refused, never applied, and + // anything else unreliable is dropped as before. Either way the peer is not idle. + if let Some(entry) = self.sessions.get_mut(&key) { + entry.last_activity_monotonic_millis = now_monotonic; + } return self.refuse_unreliable_write(key, &packet.payload); } + // A receipt session's write on a sequenced channel (which drops superseded packets) is refused too, but + // only after the session has received and acknowledged it: an unacknowledged packet is resent forever + // and stalls every later ordered write. + let refuse_sequenced = is_schema_data && packet.sequenced && self.receipts(key); let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { packet.payload.len() } else { @@ -821,6 +825,14 @@ where if frame.channel_id != "schema" { continue; } + if refuse_sequenced { + if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = + self.refuse_unreliable_write(key, &frame.payload)? + { + kept_rejection = Some(rejection); + } + continue; + } // The capped frame was answered: nothing after it is served while the answer is delivered. if self .sessions diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index ac43c21d..cccff92f 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2963,11 +2963,8 @@ fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Res let unordered = send_with(&mut peer, &mut server, "s1", false, false)?; assert!(unordered.contains(&admitted("s1", "note:1")), "reliable unordered is accepted, and the id was not consumed: {unordered:?}"); - // A fresh peer: an ordered stream waits on the sequence numbers the refused packet used. - let mut ordered_peer = RawPeer::connect(&mut server, 251)?; - ordered_peer.send(&receipt_hello())?; - server.poll_once()?; - let ordered = send_with(&mut ordered_peer, &mut server, "o1", true, false)?; + // The same peer: the refused packet was acknowledged, so an ordered stream does not wait on its sequence number. + let ordered = send_with(&mut peer, &mut server, "o1", true, false)?; assert!(ordered.contains(&admitted("o1", "note:1")), "reliable ordered is accepted: {ordered:?}"); assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); @@ -2986,6 +2983,35 @@ fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Res Ok(()) } +/// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured +/// from the refusal and not from the packet before it. +#[test] +fn a_refused_write_keeps_the_session_from_going_idle() -> Result<()> { + let clock = Clock::new(70_000); + let options = CultMeshRudpDocumentServerOptions { session_idle_timeout: Duration::from_secs(1), ..Default::default() }; + let mut server = server(options, clock.clone(), Sink::default(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 252)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + for (at, reliable, sequenced) in [(70_700, false, false), (71_400, true, true)] { + clock.set(at); + let payload = encode_cultnet_message_to_vec(&put("idle", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; + let packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable, ordered: false, sequenced, now_ms: at, reliable_expire_after_ms: None }, + Some(1200), + )?; + for packet in &packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + assert_eq!(server.maintain()?.sessions_expired, 0, "the refused write at {at} is activity"); + } + assert_eq!(server.session_count(), 1); + Ok(()) +} + /// A Connect that does not repeat the accepted one is a new client session: it starts with nothing remembered, so /// the id a receipt session already used is a new write on it, and the old session's entries leave the budget. #[test] From 621b86ca15f8d7f79a03e6cdd1c4d2808a1d9abd Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:45:19 +0000 Subject: [PATCH 31/31] Spec #59: an empty or blank schemaId or recordKey is message_invalid (details.field message, the write's id), never applied; vector for it Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/interop/write-receipt-vectors.json | 17 +++++++++++++++++ contracts/cultnet/interop/write-receipt.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index f575398d..c0313569 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -75,6 +75,23 @@ "messageId": null } }, + { + "name": "error_v1_empty_schema_id_is_unreadable_and_names_the_write", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D31", + "message": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid: unreadable frame", + "routingHint": null, + "code": "message_invalid", + "details": { + "field": "message", + "value": null, + "asOf": null, + "current": null + }, + "messageId": "put-1" + } + }, { "name": "error_v1_limit_reached", "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235", diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index ce708ca6..75da775f 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,7 +36,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. + - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A `schemaId` or `recordKey` that is empty or only whitespace (present but names nothing) is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"` and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. - Past the session (or a server's longer retention, if it keeps one) a resend is a new write.