From 662ae048d8621911cd0480eb862104660128bcbd Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:28:46 +0000 Subject: [PATCH 01/10] 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. -- 2.47.3 From ab7bc771e379e98d84ea655db5c27a8802300b85 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:31:49 +0000 Subject: [PATCH 02/10] 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) -- 2.47.3 From 7a560b1984032c07268a469385b827674170d2fa Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 13:58:39 +0000 Subject: [PATCH 03/10] 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. -- 2.47.3 From eafcdcdf7c130b9539458c3506df7f47bcfdcd08 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:29:18 +0000 Subject: [PATCH 04/10] 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. -- 2.47.3 From b89a8837003517ea0633fe46c63d2f6e517895bf Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:58:51 +0000 Subject: [PATCH 05/10] 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. -- 2.47.3 From 734087766ae84a407367239c960ea80cdb151a3f Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:04:21 +0000 Subject: [PATCH 06/10] 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) -- 2.47.3 From 3443e1dc939cf9676ea1aad5b0c7126f3376f790 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:34:19 +0000 Subject: [PATCH 07/10] 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. -- 2.47.3 From 2c5203dc82a11014ce9a7cbe2a07440fe12574b5 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:34:52 +0000 Subject: [PATCH 08/10] 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. -- 2.47.3 From 74adad916ab25c605cd964d73dea7c6016b4f2be Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:59:39 +0000 Subject: [PATCH 09/10] 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 -- 2.47.3 From 621b86ca15f8d7f79a03e6cdd1c4d2808a1d9abd Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:45:19 +0000 Subject: [PATCH 10/10] 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. -- 2.47.3