diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index c0313569..397fbad6 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -60,10 +60,10 @@ }, { "name": "error_v1_unreadable_frame_has_null_message_id", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964C0", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D92E6D6573736167655F696E76616C69643A20746865206D65737361676520636F756C64206E6F742062652072656164AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964C0", "message": { "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: unreadable frame", + "error": "message_invalid: the message could not be read", "routingHint": null, "code": "message_invalid", "details": { @@ -77,10 +77,10 @@ }, { "name": "error_v1_empty_schema_id_is_unreadable_and_names_the_write", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D31", + "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D92E6D6573736167655F696E76616C69643A20746865206D65737361676520636F756C64206E6F742062652072656164AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D31", "message": { "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: unreadable frame", + "error": "message_invalid: the message could not be read", "routingHint": null, "code": "message_invalid", "details": { @@ -110,6 +110,45 @@ } } ], + "blankFields": { + "semantics": "A put whose document 'names nothing' is refused like a frame that does not read (error.v1 message_invalid, details.field \"message\", the write's id, the fixed text of error_v1_empty_schema_id_is_unreadable_and_names_the_write) and never applied, on every connection, receipt-capable or not. Each case starts from this valid document: schemaId note.v0, recordKey note:1, storedAt 2026-11-08T00:00:00Z, payload 81A16101 (hex), no source ids, no tags; 'set' replaces or adds the named fields. A blank string is empty or made only of Unicode White_Space code points (the list in whiteSpaceCodePoints, the PropList property, the same in every runtime; U+200B, U+FEFF, U+180E and U+001C to U+001F are not White_Space, though some platforms' whitespace tests accept some of them). Fields not listed are valid. 'unset' means the key is absent or nil, which is not blank: those cases are admitted.", + "validPutPayloadHex": "81A16101", + "whiteSpaceCodePoints": ["0009","000A","000B","000C","000D","0020","0085","00A0","1680","2000","2001","2002","2003","2004","2005","2006","2007","2008","2009","200A","2028","2029","202F","205F","3000"], + "notWhiteSpaceCodePoints": ["200B","200C","200D","2060","FEFF","180E","001C","001D","001E","001F"], + "codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string is not blank and the put is admitted.", + "cases": [ + { "name": "valid_document_is_admitted", "set": {}, "outcome": "admitted" }, + { "name": "empty_schema_id", "set": { "schemaId": "" }, "outcome": "refused" }, + { "name": "blank_schema_id", "set": { "schemaId": " " }, "outcome": "refused" }, + { "name": "empty_record_key", "set": { "recordKey": "" }, "outcome": "refused" }, + { "name": "blank_record_key", "set": { "recordKey": " " }, "outcome": "refused" }, + { "name": "empty_stored_at", "set": { "storedAt": "" }, "outcome": "refused" }, + { "name": "blank_stored_at", "set": { "storedAt": " " }, "outcome": "refused" }, + { "name": "empty_payload", "set": { "payloadHex": "" }, "outcome": "refused" }, + { "name": "blank_source_runtime_id", "set": { "sourceRuntimeId": "" }, "outcome": "refused" }, + { "name": "blank_source_agent_id", "set": { "sourceAgentId": " " }, "outcome": "refused" }, + { "name": "blank_source_role", "set": { "sourceRole": "" }, "outcome": "refused" }, + { "name": "blank_tag", "set": { "tags": ["a", " "] }, "outcome": "refused" }, + { "name": "nbsp_schema_id", "set": { "schemaId": "\u00a0" }, "outcome": "refused" }, + { "name": "em_space_record_key", "set": { "recordKey": "\u2003\u2003" }, "outcome": "refused" }, + { "name": "next_line_stored_at", "set": { "storedAt": "\u0085" }, "outcome": "refused" }, + { "name": "line_separator_source_runtime_id", "set": { "sourceRuntimeId": "\u2028" }, "outcome": "refused" }, + { "name": "ideographic_space_source_agent_id", "set": { "sourceAgentId": "\u3000" }, "outcome": "refused" }, + { "name": "nbsp_source_role", "set": { "sourceRole": " \u00a0 " }, "outcome": "refused" }, + { "name": "em_space_tag", "set": { "tags": ["a", "\u2003"] }, "outcome": "refused" }, + { "name": "next_line_tag", "set": { "tags": ["\u0085"] }, "outcome": "refused" }, + { "name": "zero_width_space_schema_id_is_a_name", "set": { "schemaId": "\u200b" }, "outcome": "admitted" }, + { "name": "zero_width_space_record_key_is_a_name", "set": { "recordKey": "\u200b" }, "outcome": "admitted" }, + { "name": "zero_width_space_tag_is_a_name", "set": { "tags": ["\u200b"] }, "outcome": "admitted" }, + { "name": "bom_stored_at_is_a_name", "set": { "storedAt": "\ufeff" }, "outcome": "admitted" }, + { "name": "mongolian_vowel_separator_source_role_is_a_name", "set": { "sourceRole": "\u180e" }, "outcome": "admitted" }, + { "name": "padded_name_is_a_name", "set": { "schemaId": "\u00a0a\u2003", "tags": [" a"] }, "outcome": "admitted" }, + { "name": "file_separator_schema_id_is_a_name", "set": { "schemaId": "\u001c" }, "outcome": "admitted" }, + { "name": "source_ids_and_tags_present_and_named", "set": { "sourceRuntimeId": "r", "sourceAgentId": "a", "sourceRole": "w", "tags": ["a"] }, "outcome": "admitted" }, + { "name": "source_ids_and_tags_nil", "set": { "sourceRuntimeId": null, "sourceAgentId": null, "sourceRole": null, "tags": null }, "outcome": "admitted" }, + { "name": "empty_tag_list", "set": { "tags": [] }, "outcome": "admitted" } + ] + }, "schemaInvalid": [ { "name": "write_admitted_with_an_error_key", @@ -215,7 +254,7 @@ } ], "scenarios": { - "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.", + "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. An event is atMs plus exactly one of: send (a write goes out), frame (a decoded frame arrives), transportAck (delivery, never an answer; the tracker has no input for it), tick (the clock advances), sendFailed (the client's own send of that write failed), sessionEnd (the session ended). Any other key is a defect in the vectors or the runner. A window ends when a write's age reaches it, so an event at exactly sentAt+window finds the write already unconfirmed. '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", @@ -870,6 +909,266 @@ "outcome": "admitted" } } + }, + { + "name": "error_v1_without_id_with_one_write_in_flight_refuses", + "description": "An error.v1 whose messageId is nil names no write: like error.v0 it is attributable only because one write is unanswered.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 30, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid", + "routingHint": null, + "code": "message_invalid", + "details": null, + "messageId": null + } + } + ], + "expect": { + "w1": { + "outcome": "refused", + "code": "message_invalid" + } + } + }, + { + "name": "error_v1_without_id_with_two_writes_in_flight_is_unattributed", + "description": "An error.v1 with a nil messageId settles neither of two unanswered writes; each ends unconfirmed at its window.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1", + "w2" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 1, + "send": "w2" + }, + { + "atMs": 30, + "frame": { + "schemaVersion": "cultnet.error.v1", + "error": "message_invalid", + "routingHint": null, + "code": "message_invalid", + "details": null, + "messageId": null + } + }, + { + "atMs": 5001, + "tick": true + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + }, + "w2": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "send_failed_is_unconfirmed_and_touches_no_other_write", + "description": "The client's own send of w1 failed: w1 is unconfirmed local_send_failed, never refused; w2 is unaffected and settles on its own receipt.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1", + "w2" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 1, + "send": "w2" + }, + { + "atMs": 2, + "sendFailed": "w1" + }, + { + "atMs": 20, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w2", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "local_send_failed" + }, + "w2": { + "outcome": "admitted" + } + } + }, + { + "name": "error_without_id_after_the_window_settles_nothing", + "description": "Once w1 is unconfirmed no write is unanswered, so an error that names no write is attributed to nothing and records nothing on w1.", + "server": "legacy", + "window": "LEGACY_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 500, + "tick": true + }, + { + "atMs": 600, + "frame": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": null + } + } + ], + "expect": { + "w1": { + "outcome": "unconfirmed", + "reason": "window_elapsed" + } + } + }, + { + "name": "receipt_one_millisecond_inside_the_window_admits", + "description": "A receipt 1 ms before the window ends settles the write admitted.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 4999, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + } + ], + "expect": { + "w1": { + "outcome": "admitted" + } + } + }, + { + "name": "receipt_at_the_exact_window_boundary_is_late", + "description": "The window ends when the write's age reaches it: a receipt at exactly that instant finds w1 already unconfirmed and is recorded admitted_late.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5000, + "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": "send_after_session_end_is_unconfirmed_at_once", + "description": "A write registered after the session ended can never be answered: it settles unconfirmed session_ended when sent, and the earlier write keeps its own receipt.", + "server": "receipt", + "window": "RECEIPT_RESULT_WINDOW_MS", + "writes": [ + "w1", + "w2" + ], + "events": [ + { + "atMs": 0, + "send": "w1" + }, + { + "atMs": 5, + "frame": { + "schemaVersion": "cultnet.write_admitted.v0", + "messageId": "w1", + "operation": "put", + "schemaId": "note.v0", + "recordKey": "note:1" + } + }, + { + "atMs": 40, + "sessionEnd": true + }, + { + "atMs": 50, + "send": "w2" + } + ], + "expect": { + "w1": { + "outcome": "admitted" + }, + "w2": { + "outcome": "unconfirmed", + "reason": "session_ended" + } + } } ] }, @@ -878,6 +1177,13 @@ "LEGACY_RESULT_WINDOW_MS": 500, "DEDUPE_RETENTION_MS": 600000 }, + "counts": { + "semantics": "How many of each a runner must have run. A runner that reads fewer (a parser that drops entries, a loop over nothing) fails instead of passing quietly; a runner that meets an event key outside scenarios.semantics fails too.", + "wire": 7, + "schemaInvalid": 7, + "contentHashCases": 4, + "scenarioCases": 25 + }, "contentHash": { "definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase", "cases": [ diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 8581cc53..53639d52 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,9 +36,9 @@ 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. 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. + - **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 put or delete that is 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"`, the fixed unreadable text 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. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case. + - **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: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `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. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1). - 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. @@ -49,9 +49,9 @@ 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), `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. +- **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; a write registered after the session ended settles this way when it is sent) 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. +**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. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. 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. 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. @@ -65,7 +65,7 @@ Those clients complete *successfully* when the window ends with no refusal. Unde ## Vectors -`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. +`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 `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `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 diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index f97b2578..9aae5be9 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -780,6 +780,13 @@ where return Ok(CultMeshRudpPollOutcome::Handled); } + // Whether this packet is the first the session takes for its sequence: a repeat of an answered write, or a + // packet too far ahead to be remembered, is not, and the write is not answered for it again. + let first_receipt = packet.reliable + && self + .sessions + .get(&key) + .is_some_and(|entry| !entry.session.has_received(packet.sequence)); let result = { let entry = self .sessions @@ -821,11 +828,28 @@ where } let mut kept_rejection = None; + // A superseded packet delivers no frame, yet it was acknowledged and will not be resent, so its write would + // go unanswered. A packet that carries its whole frame is answered from its own payload. A superseded + // fragment cannot be: the write's id may lie in a fragment this packet does not hold. + if refuse_sequenced + && first_receipt + && self.sessions.get(&key).is_some_and(|entry| entry.session.has_received(packet.sequence)) + && packet.fragment_count == 0 + && !result.delivered.iter().any(|frame| frame.channel_id == "schema" && frame.sequence == packet.sequence) + { + if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = + self.refuse_unreliable_write(key, &packet.payload)? + { + kept_rejection = Some(rejection); + } + } for frame in result.delivered { if frame.channel_id != "schema" { continue; } - if refuse_sequenced { + // `delivered` also holds the ordered frames this packet's arrival released from the hold; those were + // not sent on the sequenced channel. They sit above the packet's own sequence, its own frame at or below. + if refuse_sequenced && sequence_at_or_before(frame.sequence, packet.sequence) { if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = self.refuse_unreliable_write(key, &frame.payload)? { @@ -1814,6 +1838,13 @@ where #[cfg(test)] mod send_failure_tests; +/// Whether `sequence` is at or before `mark` in serial order (RFC 1982): the 32-bit space is compared modulo 2^32, +/// so a frame that began before the wrap and a packet after it still order correctly. The receive window of +/// `cultnet_rs`' duplicate test does not belong here: a frame's first fragment may lie any distance back. +fn sequence_at_or_before(sequence: u32, mark: u32) -> bool { + mark.wrapping_sub(sequence) < 1 << 31 +} + fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { if options.max_sessions == 0 { return Err(anyhow!("max_sessions must be greater than zero")); @@ -1929,4 +1960,17 @@ mod tests { .unwrap(); assert_eq!(hex(&bytes), MESSAGE_INVALID_HEX); } + + #[test] + fn sequence_order_holds_across_the_wrap_and_beyond_the_receive_window() { + assert!(sequence_at_or_before(7, 7)); + assert!(sequence_at_or_before(100, 5_200), "further back than the receive window"); + assert!(!sequence_at_or_before(5_200, 100)); + assert!(sequence_at_or_before(u32::MAX - 1, 1), "begun before the wrap, ended after it"); + assert!(!sequence_at_or_before(1, u32::MAX - 1), "after the wrap is later than before it"); + assert!(sequence_at_or_before(u32::MAX, 0), "the wrap itself"); + assert!(!sequence_at_or_before(0, u32::MAX)); + assert!(sequence_at_or_before(0, (1 << 31) - 1), "just inside half the space"); + assert!(!sequence_at_or_before(0, 1 << 31), "half the space away is not before"); + } } diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index cccff92f..60596ee5 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2983,6 +2983,324 @@ fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Res Ok(()) } +/// `bytes` with the MessagePack fixstr `from` replaced by the fixstr `to`, which may be blank. +fn swap_fixstr(bytes: &[u8], from: &str, to: &str) -> Vec { + assert!(from.len() < 32 && to.len() < 32); + let mut needle = vec![0xa0 | from.len() as u8]; + needle.extend_from_slice(from.as_bytes()); + let at = bytes.windows(needle.len()).position(|window| window == needle).expect("marker is in the frame"); + let mut out = bytes[..at].to_vec(); + out.push(0xa0 | to.len() as u8); + out.extend_from_slice(to.as_bytes()); + out.extend_from_slice(&bytes[at + needle.len()..]); + out +} + +/// A `schemaId` or `recordKey` that is present but empty or only whitespace names nothing: the write is refused +/// `message_invalid` like a frame that does not read (field "message", the write's id), never reaches the store, +/// and the session goes on (write-receipt.md, rule 5; vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`). +#[test] +fn a_write_whose_schema_id_or_record_key_names_nothing_is_refused_and_never_handed_to_the_store() -> Result<()> { + let blanks = ["", " ", "\t \n"]; + let mut connection_id = 300; + for blank in blanks { + for blank_field in ["schemaId", "recordKey"] { + for operation in ["put", "delete"] { + connection_id += 1; + let (schema_id, record_key) = match blank_field { + "schemaId" => (blank, "note:1"), + _ => ("test.raw.v1", blank), + }; + // The encoder refuses a blank field, so encode a marker and swap the blank into the bytes. + let bad = if operation == "put" { + let mut record = document("zz-key-zz", vec![1, 2, 3]); + record.schema_id = "zz-schema-zz".into(); + encode_cultnet_message_to_vec( + &CultNetMessage::DocumentPutRaw { message_id: "bad-1".into(), document: record }, + CultNetWireContract::CultNetSchemaV0, + )? + } else { + encode_cultnet_message_to_vec( + &CultNetMessage::DocumentDelete { + message_id: "bad-1".into(), + schema_id: "zz-schema-zz".into(), + record_key: "zz-key-zz".into(), + }, + CultNetWireContract::CultNetSchemaV0, + )? + }; + let bad = swap_fixstr(&bad, "zz-schema-zz", schema_id); + let bad = swap_fixstr(&bad, "zz-key-zz", record_key); + let label = format!("{operation} with {blank_field} {blank:?}"); + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, connection_id)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + peer.send_reliable("schema", bad)?; + send_reliable(&mut peer, &put("good-1", "note:2", vec![1]))?; + let answers = answers_after(&mut server, &mut peer, 2)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into())), "{label}"); + assert_eq!(error_text_and_field(&answers[0]).1, Some("message".into()), "{label}"); + assert_eq!(answers[1], admitted("good-1", "note:2"), "{label}: the session goes on"); + let state = sink.0.lock().unwrap(); + assert_eq!(state.authorized, vec!["good-1".to_string()], "{label}: the store was never asked about the blank write"); + assert_eq!(state.receipts.len(), 1, "{label}: only the good write was applied"); + } + } + } + Ok(()) +} + +/// The sequenced-channel refusal belongs to the frame that arrived sequenced. A reliable-sequenced packet that +/// fills the gap an earlier ordered packet was held behind delivers both frames in one go: the sequenced write is +/// refused, the ordered write it released is admitted. +#[test] +fn a_sequenced_packet_releasing_a_held_ordered_write_refuses_only_its_own() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 250)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let mut packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result> { + let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?; + peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + ) + }; + let sequenced = packet_for(&mut peer, "s1", false, true)?; + let ordered = packet_for(&mut peer, "o1", true, false)?; + // The ordered write arrives first, one sequence ahead of the gap, and is held. + for packet in &ordered { + peer.send_packet(packet)?; + } + server.poll_once()?; + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the ordered write waits behind the gap"); + for packet in &sequenced { + peer.send_packet(packet)?; + } + server.poll_once()?; + let mut answers = Vec::new(); + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); + } + } + let refusals: Vec<_> = answers.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect(); + assert_eq!(refusals.len(), 1, "only the sequenced write is refused: {answers:?}"); + assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into()))); + assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "and applied"); + Ok(()) +} + +/// The refusal of a write on a sequenced channel does not depend on the packet being the newest. A reliable-sequenced +/// packet that arrives after a newer one on its channel is superseded: the session delivers no frame for it, yet +/// the packet was acknowledged and will not be resent, so the server answers it from the packet's own payload. +#[test] +fn a_superseded_sequenced_write_is_still_refused() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 253)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let packet_for = |peer: &mut RawPeer, id: &str| -> Result> { + let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?; + peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + ) + }; + let older = packet_for(&mut peer, "old")?; + let newer = packet_for(&mut peer, "new")?; + let mut refused_ids = Vec::new(); + for packets in [&newer, &older] { + for packet in packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + if matches!(answer, CultNetMessage::ErrorV1 { .. }) { + refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write")); + } + } + } + } + assert_eq!(refused_ids, vec!["new".to_string(), "old".to_string()], "the superseded write is answered too"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied"); + Ok(()) +} + +/// One answer per write (write-receipt.md, rule 1): a sequenced packet the server already answered is answered +/// once, however often the peer sends it again, and a packet too far ahead to be received draws nothing until it +/// is received (the peer resends it, and the answer comes then). +#[test] +fn a_repeated_or_far_ahead_sequenced_write_draws_one_answer_not_one_per_send() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 256)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let packet_for = |peer: &mut RawPeer, id: &str| -> Result> { + let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?; + peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + ) + }; + let refusals_of = |peer: &mut RawPeer| -> Result> { + let mut ids = Vec::new(); + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + if matches!(answer, CultNetMessage::ErrorV1 { .. }) { + ids.push(refusal_of(&answer).1.expect("a refusal names its write")); + } + } + } + Ok(ids) + }; + // The same packet, sent three times (a resend whose acknowledgement was lost), is refused once. + let once = packet_for(&mut peer, "dup")?; + let mut refused = Vec::new(); + for _ in 0..3 { + for packet in &once { + peer.send_packet(packet)?; + } + server.poll_once()?; + refused.extend(refusals_of(&mut peer)?); + } + assert_eq!(refused, vec!["dup".to_string()], "a repeat of an answered write is not answered again"); + + // A packet far past the receive window is not received: no answer yet, and none per resend. + let ahead = packet_for(&mut peer, "ahead")?; + let mut far = ahead[0].clone(); + far.sequence = once[0].sequence + 5_000; + let mut refused = Vec::new(); + for _ in 0..3 { + peer.send_packet(&far)?; + server.poll_once()?; + refused.extend(refusals_of(&mut peer)?); + } + assert_eq!(refused, Vec::::new(), "an unreceived packet draws no answer"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); + Ok(()) +} + +/// A superseded sequenced packet is answered even when its arrival fills a gap and releases another write: the +/// frames that arrive with it do not stand in for its own. +#[test] +fn a_superseded_sequenced_write_is_refused_while_it_releases_a_held_ordered_write() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 255)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result> { + let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?; + peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None }, + Some(1200), + ) + }; + let older = packet_for(&mut peer, "old", false, true)?; + let ordered = packet_for(&mut peer, "o1", true, false)?; + let newer = packet_for(&mut peer, "new", false, true)?; + let mut answers = Vec::new(); + for packets in [&newer, &ordered, &older] { + for packet in packets { + peer.send_packet(packet)?; + } + server.poll_once()?; + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); + } + } + } + let mut refused: Vec = answers + .iter() + .filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })) + .map(|a| refusal_of(a).1.expect("a refusal names its write")) + .collect(); + refused.sort(); + assert_eq!(refused, vec!["new".to_string(), "old".to_string()], "{answers:?}"); + assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}"); + Ok(()) +} + +/// The refusal reaches a sequenced write however far apart its fragments' sequences sit: other traffic can take the +/// sequences between the first fragment and the last, further back than the duplicate test's receive window. +#[test] +fn a_sequenced_write_whose_fragments_span_more_than_the_receive_window_is_refused() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = RawPeer::connect(&mut server, 254)?; + peer.send(&receipt_hello())?; + server.poll_once()?; + let payload = encode_cultnet_message_to_vec(&put("far", "note:far", vec![1; 64]), CultNetWireContract::CultNetSchemaV0)?; + let mut packets = peer.session.send_many( + "schema", + payload, + cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None }, + Some(32), + )?; + let last = packets.pop().expect("the write is fragmented"); + assert!(!packets.is_empty()); + let mut next = last.sequence; + let mut refused_ids = Vec::new(); + for packet in &packets { + peer.send_packet(packet)?; + server.poll_once()?; + } + // 5,000 reliable packets on another channel, in order, take the sequences between the fragments. + for count in 0..5_000 { + let mut filler = packets[0].clone(); + filler.channel_id = "filler".into(); + filler.sequenced = false; + filler.fragment_id = 0; + filler.fragment_index = 0; + filler.fragment_count = 0; + filler.payload = vec![0]; + filler.sequence = next; + next += 1; + peer.send_packet(&filler)?; + server.poll_once()?; + if count % 100 == 99 { + // Read what the server sent, so the socket buffer never holds back the refusal. + for packet in peer.drain() { + peer.session.receive(&packet, 4)?; + } + } + } + let mut last = last; + last.sequence = next; + peer.send_packet(&last)?; + server.poll_once()?; + for packet in peer.drain() { + for frame in peer.session.receive(&packet, 4)?.delivered { + let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + if matches!(answer, CultNetMessage::ErrorV1 { .. }) { + refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write")); + } + } + } + assert_eq!(refused_ids, vec!["far".to_string()]); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied"); + Ok(()) +} + /// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured /// from the refusal and not from the packet before it. #[test] @@ -3122,3 +3440,189 @@ fn an_unreliable_refusal_that_cannot_be_queued_ends_the_session_as_a_local_failu assert_eq!(goodbye.payload, b"server could not answer a write".to_vec()); Ok(()) } + +fn vectors() -> serde_json::Value { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json"); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +/// The spec's one fixed text for a frame that does not read or a write that names nothing, read from the vector +/// that carries it, so a server and the spec cannot drift apart unseen. +fn unreadable_vector_text(vectors: &serde_json::Value) -> String { + let name = "error_v1_empty_schema_id_is_unreadable_and_names_the_write"; + let vector = vectors["wire"].as_array().unwrap().iter().find(|v| v["name"] == name).unwrap(); + vector["message"]["error"].as_str().unwrap().to_string() +} + +/// The put frame of a `blankFields` case: the valid document with the case's `set` applied. Serialized without the +/// typed encoder, because that refuses what the case is about. +fn blank_field_put(vectors: &serde_json::Value, set: &serde_json::Value) -> Vec { + blank_field_put_with_id(vectors, set, "m1") +} + +fn blank_field_put_with_id(vectors: &serde_json::Value, set: &serde_json::Value, message_id: &str) -> Vec { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Put { + schema_version: &'static str, + message_id: String, + document: CultNetRawDocumentRecord, + } + let hex = set.get("payloadHex").and_then(|v| v.as_str()).unwrap_or(vectors["blankFields"]["validPutPayloadHex"].as_str().unwrap()); + let field = |name: &str, default: &str| set.get(name).and_then(|v| v.as_str()).unwrap_or(default).to_string(); + let optional = |name: &str| set.get(name).and_then(|v| v.as_str()).map(str::to_string); + let document = CultNetRawDocumentRecord { + schema_id: field("schemaId", "note.v0"), + record_key: field("recordKey", "note:1"), + stored_at: field("storedAt", "2026-11-08T00:00:00Z"), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect(), + source_runtime_id: optional("sourceRuntimeId"), + source_agent_id: optional("sourceAgentId"), + source_role: optional("sourceRole"), + tags: set.get("tags").and_then(|v| v.as_array()).map(|tags| tags.iter().map(|t| t.as_str().unwrap().to_string()).collect()), + }; + rmp_serde::to_vec_named(&Put { schema_version: "cultnet.document_put_raw.v0", message_id: message_id.to_string(), document }).unwrap() +} + +/// Each `blankFields` case: a receipt-capable connection is answered with the admitted receipt, or with the one fixed +/// unreadable text naming the write, and the sink sees a document only when the case is admitted. +#[test] +fn a_put_that_names_nothing_is_refused_as_unreadable_naming_the_write_and_a_named_one_is_admitted() -> Result<()> { + let vectors = vectors(); + let fixed = unreadable_vector_text(&vectors); + assert_eq!(fixed, "message_invalid: the message could not be read"); + let cases = vectors["blankFields"]["cases"].as_array().unwrap(); + assert!(cases.len() >= 10); + for (index, case) in cases.iter().enumerate() { + let name = case["name"].as_str().unwrap(); + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 400 + index as u32)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + peer.send_reliable("schema", blank_field_put(&vectors, &case["set"]))?; + let answers = answers_after(&mut server, &mut peer, 1)?; + match case["outcome"].as_str().unwrap() { + "admitted" => { + assert_eq!( + answers, + vec![CultNetMessage::WriteAdmitted { + message_id: "m1".into(), + operation: "put".into(), + schema_id: case["set"]["schemaId"].as_str().unwrap_or("note.v0").into(), + record_key: case["set"]["recordKey"].as_str().unwrap_or("note:1").into(), + }], + "{name}" + ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "{name}"); + } + "refused" => { + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into())), "{name}"); + assert_eq!(error_text_and_field(&answers[0]), (fixed.clone(), Some("message".into())), "{name}"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "{name}: never applied"); + } + other => panic!("{name}: unknown outcome {other}"), + } + assert_eq!(server.session_count(), 1, "{name}: the session is kept"); + } + Ok(()) +} + +fn code_point_string(hex: &str, times: usize) -> String { + std::char::from_u32(u32::from_str_radix(hex, 16).unwrap()).unwrap().to_string().repeat(times) +} + +/// The Unicode White_Space set is the one blank rule: every listed code point, alone or repeated, blanks every field +/// the rule covers; U+200B, U+FEFF, U+180E and the other listed non-White_Space code points name something. +#[test] +fn blank_means_exactly_the_unicode_white_space_code_points_in_every_field() -> Result<()> { + let vectors = vectors(); + let fixed = unreadable_vector_text(&vectors); + let fields = ["schemaId", "recordKey", "storedAt", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags"]; + let space = vectors["blankFields"]["whiteSpaceCodePoints"].as_array().unwrap(); + let not_space = vectors["blankFields"]["notWhiteSpaceCodePoints"].as_array().unwrap(); + assert_eq!(space.len(), 25); + for (index, (hex, blank)) in space.iter().map(|h| (h, true)).chain(not_space.iter().map(|h| (h, false))).enumerate() { + let hex = hex.as_str().unwrap(); + for (field_index, field) in fields.iter().enumerate() { + // A session that is sent refusals is allowed to end, so each code point and field gets its own. + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 600 + (index * fields.len() + field_index) as u32)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![server_hello()]); + let mut admitted = 0; + for times in [1, 3] { + let text = code_point_string(hex, times); + let value = if *field == "tags" { serde_json::json!(["a", text]) } else { serde_json::json!(text) }; + let set = serde_json::json!({ *field: value }); + let id = format!("{hex}-{field}-{times}"); + peer.send_reliable("schema", blank_field_put_with_id(&vectors, &set, &id))?; + let answers = frames_after(&mut server, &mut peer, 1)?; + let what = format!("U+{hex} in {field} x{times}"); + assert!(!answers.is_empty(), "{what}: no answer"); + if blank { + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some(id)), "{what}"); + assert_eq!(error_text_and_field(&answers[0]), (fixed.clone(), Some("message".into())), "{what}"); + } else { + assert!(matches!(answers[0], CultNetMessage::WriteAdmitted { .. }), "{what} is a name: {:?}", answers[0]); + admitted += 1; + } + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), admitted, "U+{hex} in {field}: only named writes are applied"); + } + } + Ok(()) +} + +/// The same cases on a connection that never opted in: the same refusal as `error.v0`, the same text, nothing applied. +#[test] +fn a_put_that_names_nothing_is_refused_the_same_way_on_a_legacy_connection() -> Result<()> { + let vectors = vectors(); + let fixed = unreadable_vector_text(&vectors); + for (index, case) in vectors["blankFields"]["cases"].as_array().unwrap().iter().enumerate() { + let name = case["name"].as_str().unwrap(); + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 500 + index as u32)?; + connect(&mut server, &mut [&mut peer])?; + let bytes = blank_field_put(&vectors, &case["set"]); + if case["outcome"] == "refused" { + let (code, details, error) = refusal_on_live_session(&mut server, &mut peer, bytes)?; + assert_eq!(code, Some(CultNetErrorCode::MessageInvalid), "{name}"); + assert_eq!(details.and_then(|d| d.field), Some("message".into()), "{name}"); + assert_eq!(error, fixed, "{name}"); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "{name}"); + } else { + peer.send_reliable("schema", bytes)?; + for _ in 0..2_000 { + server.poll_once()?; + if !sink.0.lock().unwrap().receipts.is_empty() { + break; + } + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "{name}"); + } + } + Ok(()) +} + +/// An unreadable frame from a receipt connection is sent the one fixed text, with no decoder wording in it. +#[test] +fn an_unreadable_frame_on_a_receipt_connection_is_sent_exactly_the_fixed_text() -> Result<()> { + let vectors = vectors(); + let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; + let mut peer = client(server.local_addr()?, 450)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + peer.send_reliable("schema", garbage())?; + let answers = answers_after(&mut server, &mut peer, 1)?; + assert_eq!( + error_text_and_field(&answers[0]), + (unreadable_vector_text(&vectors), Some("message".into())) + ); + Ok(()) +} diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index eae7a2ee..427a38c1 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -1277,6 +1277,12 @@ impl CultNetRudpSession { } } + /// Whether `receive` has taken this reliable sequence: delivered, held, superseded or a duplicate. A packet + /// `receive` refused unremembered (too far ahead, or before the handshake) is not, and its sender resends it. + pub fn has_received(&self, sequence: u32) -> bool { + self.was_received(sequence) + } + /// The one duplicate test: true for a sequence in the window that was /// received, for one below the window, and for one at or before the /// watermark. The watermark starts at the handshake's seed, so a frame the diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs index 6d06a68c..3d233a61 100644 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ b/packages/cultnet-rs/tests/write_receipt.rs @@ -39,7 +39,7 @@ fn the_new_frames_decode_back_equal() { ); // error.v1 with a nil id round-trips as nil, and the code enum is v0's. let error = CultNetMessage::ErrorV1 { - error: "message_invalid: unreadable frame".into(), + error: "message_invalid: the message could not be read".into(), code: Some(CultNetErrorCode::MessageInvalid), details: None, message_id: None, diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index be6e2d98..e7a5f1f1 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Added +- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`. - Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`. - `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`. - `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority. diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 6e05c9a5..3cc87dcd 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -1422,7 +1422,7 @@ function validateBinaryMessageRoot( if (candidate.schemaVersion !== schemaVersion) { throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`); } - if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) { + if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) { throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`); } @@ -1460,8 +1460,13 @@ function validateRawDocumentRecord( requireOptionalStringArray(candidate.tags, `${instancePath}/tags`); } +/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */ +function isBlank(value: string): boolean { + return /^\p{White_Space}*$/u.test(value); +} + function requireNonEmptyString(input: unknown, instancePath: string): void { - if (typeof input !== "string" || input.trim().length === 0) { + if (typeof input !== "string" || isBlank(input)) { throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`); } } @@ -1477,7 +1482,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void if (typeof input === "undefined" || input === null) { return; } - if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) { + if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) { throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`); } } diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts index 2204d371..cedf877d 100644 --- a/packages/cultnet-ts/src/write-tracker.ts +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -49,6 +49,7 @@ export class WriteTracker { readonly #windowMs: number; readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void; readonly #writes = new Map(); + #ended = false; constructor(options: WriteTrackerOptions) { this.#windowMs = options.windowMs; @@ -58,7 +59,11 @@ export class WriteTracker { /** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */ send(messageId: string, atMs: number): void { this.tick(atMs); - if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } }); + if (this.#writes.has(messageId)) return; + const entry: Entry = { sentAtMs: atMs, state: { outcome: "pending" } }; + this.#writes.set(messageId, entry); + // On a session that already ended nothing can answer this write. + if (this.#ended) this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" }); } /** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */ @@ -93,9 +98,10 @@ export class WriteTracker { } } - /** The session ended: every unanswered write settles unconfirmed. */ + /** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */ sessionEnd(atMs: number): void { this.tick(atMs); + this.#ended = true; for (const [messageId, entry] of this.#writes) { if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" }); } diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 83c31411..6acb79cf 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -1,6 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -195,6 +197,38 @@ test("the replay runner passes every vector against this runtime", () => { assert.doesNotMatch(run.stdout, /^FAIL/m); }); +// A runner that skips what it cannot read passes a file it did not replay. It must fail instead. +test("the replay runner fails on an unknown event key and on a vector count the file does not declare", () => { + const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs"); + const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"); + const dir = mkdtempSync(join(tmpdir(), "receipt-vectors-")); + const runWith = (edit: (vectors: any) => void) => { + const vectors = JSON.parse(readFileSync(vectorsPath, "utf8")); + edit(vectors); + const file = join(dir, "vectors.json"); + writeFileSync(file, JSON.stringify(vectors)); + return spawnSync(process.execPath, [script, file], { encoding: "utf8" }); + }; + const typo = runWith((v) => { v.scenarios.cases[0].events[0] = { atMs: 0, sendfailed: "w1" }; }); + assert.equal(typo.status, 1, typo.stdout); + assert.match(typo.stdout, /^FAIL scenario\/receipt_admits$/m); + const dropped = runWith((v) => { v.scenarios.cases.pop(); }); + assert.equal(dropped.status, 1, dropped.stdout); + assert.match(dropped.stdout, /^FAIL counts$/m); + const undeclared = runWith((v) => { v.wire.push(v.wire[0]); }); + assert.equal(undeclared.status, 1, undeclared.stdout); +}); + +test("a write sent after the session ended settles unconfirmed session_ended at once, and the earlier receipt stands", () => { + const settled: [string, WriteSettlement][] = []; + const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) }); + tracker.send("w1", 0); + tracker.frame(admitted("w1"), 5); + tracker.sessionEnd(40); + tracker.send("w2", 50); + assert.deepEqual(settled, [["w1", { outcome: "admitted" }], ["w2", { outcome: "unconfirmed", reason: "session_ended" }]]); +}); + test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => { const settled: [string, WriteSettlement][] = []; const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) }); @@ -227,3 +261,22 @@ test("a write whose own send failed settles local_send_failed even when its wind tracker.sendFailed("w1", 1000); assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" }); }); + +// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and +// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do. +test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => { + const put = (document: Record, messageId = "p1") => ({ + schemaVersion: "cultnet.document_put_raw.v0", messageId, + document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document }, + }); + for (const blank of ["\u0085", "  ", " "]) { + assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`); + assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`); + assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`); + } + for (const named of ["", "​", "\u001c", "᠎"]) { + parseCultNetMessage(put({}, named)); + parseCultNetMessage(put({ recordKey: named })); + parseCultNetMessage(put({ tags: [named] })); + } +}); diff --git a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs index 2c4bb403..87da14e9 100644 --- a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs +++ b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs @@ -23,6 +23,9 @@ const { const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"); const vectors = JSON.parse(readFileSync(vectorsPath, "utf8")); +// The event actions scenarios.semantics names; anything else is refused, not skipped. +const EVENT_KEYS = ["send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck"]; + const results = []; const check = (name, run) => { try { @@ -79,10 +82,13 @@ for (const scenario of vectors.scenarios.cases) { told.set(id, settlement); } }); for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) { + const actions = Object.keys(event).filter((key) => key !== "atMs"); + if (actions.length !== 1 || !EVENT_KEYS.includes(actions[0])) { problems.push(`event at ${event.atMs} has keys [${actions}], wanted atMs and exactly one of ${EVENT_KEYS}`); continue; } if ("send" in event) tracker.send(event.send, event.atMs); else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs); else if ("tick" in event) tracker.tick(event.atMs); else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs); + else if ("sendFailed" in event) tracker.sendFailed(event.sendFailed, event.atMs); // transportAck: delivery is not an answer; the tracker has no such input and must not need one. } for (const [id, want] of Object.entries(scenario.expect)) { @@ -99,6 +105,16 @@ for (const scenario of vectors.scenarios.cases) { }); } +// A runner that read fewer vectors than the file declares has silently skipped some. (contentHash is not replayed here.) +check("counts", () => { + const ran = (prefix) => results.filter((r) => r.name.startsWith(prefix)).length; + const loaded = { wire: vectors.wire.length, schemaInvalid: vectors.schemaInvalid.length, scenarioCases: vectors.scenarios.cases.length }; + const executed = { wire: ran("wire/"), schemaInvalid: ran("schemaInvalid/"), scenarioCases: ran("scenario/") }; + return Object.keys(executed) + .filter((key) => loaded[key] !== vectors.counts[key] || executed[key] !== vectors.counts[key]) + .map((key) => `${key}: declared ${vectors.counts[key]}, loaded ${loaded[key]}, ran ${executed[key]}`); +}); + function sorted(value) { if (Array.isArray(value)) return value.map(sorted); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])])); diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 1746391e..e1694131 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -390,15 +390,32 @@ namespace GameCult.Networking } } - // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. - // An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust - // server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store. + // A write that names nothing is refused as a frame that does not read, exactly as the Rust server's decoder refuses it + // (write-receipt.md, "Required fields"), never handed to the store: a blank schemaId, recordKey or storedAt, an empty + // payload, and a source id or tag that is present but blank. private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) => - string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey); + IsBlank(schemaId) || IsBlank(recordKey); - private static CultNetErrorMessage UnreadableWriteTarget() => - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"); + private static bool IsUnreadableDocument(CultNetRawDocumentRecord document) => + IsUnreadableWriteTarget(document.SchemaId, document.RecordKey) + || IsBlank(document.StoredAt) + || document.Payload == null || document.Payload.Length == 0 + || IsPresentButBlank(document.SourceRuntimeId) + || IsPresentButBlank(document.SourceAgentId) + || IsPresentButBlank(document.SourceRole) + || document.Tags != null && document.Tags.Any(IsBlank); + private static bool IsPresentButBlank(string? value) => value != null && IsBlank(value); + + // Blank is the Unicode White_Space property, listed here and in write-receipt.md, not char.IsWhiteSpace: the platform's + // own test follows its Unicode tables (and has put U+180E in the set), the spec's does not move. A null is not text. + private static bool IsBlank(string? value) => value == null || value.All(IsWhiteSpaceCodePoint); + + private static bool IsWhiteSpaceCodePoint(char c) => + c is (>= '\u0009' and <= '\u000D') or '\u0020' or '\u0085' or '\u00A0' or '\u1680' + or (>= '\u2000' and <= '\u200A') or '\u2028' or '\u2029' or '\u202F' or '\u205F' or '\u3000'; + + // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) { if (write != null) write.Refused(error); @@ -439,9 +456,9 @@ namespace GameCult.Networking return; } - if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey)) + if (IsUnreadableDocument(message.Document!)) { - Refuse(peer, write, UnreadableWriteTarget()); + Refuse(peer, write, CultNetErrorMessage.ForUnreadableMessage()); return; } @@ -522,7 +539,7 @@ namespace GameCult.Networking if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey)) { - Refuse(peer, write, UnreadableWriteTarget()); + Refuse(peer, write, CultNetErrorMessage.ForUnreadableMessage()); return; } diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 090c6a45..a2211672 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -587,6 +587,13 @@ namespace GameCult.Networking Details = new CultNetErrorDetails { Field = field } }; + /// + /// The refusal of a frame that does not read, or whose write names nothing (write-receipt.md, Fixed texts): + /// message_invalid, details { field: "message" }, and the one fixed text. A decoder's own text never reaches the wire. + /// + public static CultNetErrorMessage ForUnreadableMessage() => + ForMessageInvalid("message", "the message could not be read"); + /// /// Builds the wire error for a cursor refusal (R-N): code cursor_stale with details /// { asOf, current }, or code cursor_invalid with no details. diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index fc41b2f4..d84b0263 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -3571,7 +3571,7 @@ namespace GameCult.Networking try { SendSchema(CultNetErrorV1Message.From( - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"), + CultNetErrorMessage.ForUnreadableMessage(), CultNetReadableMessageId.From(payload))); } catch (Exception) diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index c572cfbf..e42961a2 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -1,7 +1,9 @@ #nullable enable using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -1031,6 +1033,163 @@ namespace GameCult.Networking.Tests Assert.That(rig.TextOf(KeyA), Is.Null); } + private static JsonElement Vectors() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + var path = Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-receipt-vectors.json"); + return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone(); + } + + public static IEnumerable BlankFieldCases() => + Vectors().GetProperty("blankFields").GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("name").GetString()!); + + // The one fixed text of a frame that does not read or a write that names nothing, read from the vector that carries it. + private static string UnreadableVectorText() => + Vectors().GetProperty("wire").EnumerateArray() + .Single(v => v.GetProperty("name").GetString() == "error_v1_empty_schema_id_is_unreadable_and_names_the_write") + .GetProperty("message").GetProperty("error").GetString()!; + + // write-receipt-vectors.json `blankFields`: every case, on a receipt-capable and on a legacy connection. The rig's put is + // the valid document; the case's `set` replaces the named fields (the vectors' payload bytes stand for any non-empty payload). + [Test] + public async Task APutThatNamesNothingIsRefusedAsUnreadableAndANamedOneIsAdmitted( + [ValueSource(nameof(BlankFieldCases))] string name, [Values] bool capable) + { + var vectors = Vectors().GetProperty("blankFields").GetProperty("cases").EnumerateArray() + .Single(c => c.GetProperty("name").GetString() == name); + var rig = Build(); + var wire = new Wire(); + if (capable) await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + var message = rig.PutOf("m1", KeyA, "one"); + var document = message.Document!; + foreach (var field in vectors.GetProperty("set").EnumerateObject()) + { + var value = field.Value; + var text = value.ValueKind == JsonValueKind.String ? value.GetString() : null; + switch (field.Name) + { + case "schemaId": document.SchemaId = text!; break; + case "recordKey": document.RecordKey = text!; break; + case "storedAt": document.StoredAt = text!; break; + case "payloadHex": document.Payload = Convert.FromHexString(text!); break; + case "sourceRuntimeId": document.SourceRuntimeId = text; break; + case "sourceAgentId": document.SourceAgentId = text; break; + case "sourceRole": document.SourceRole = text; break; + case "tags": document.Tags = value.ValueKind == JsonValueKind.Null ? null : value.EnumerateArray().Select(t => t.GetString()!).ToArray(); break; + default: Assert.Fail($"unknown field {field.Name}"); break; + } + } + + await rig.PutHandler(message, wire.Peer); + + if (vectors.GetProperty("outcome").GetString() == "admitted") + { + if (capable) AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey); + else Assert.That(wire.Sent, Is.Empty, "a legacy connection is sent no receipt"); + if (!vectors.GetProperty("set").EnumerateObject().Any(f => f.Name is "schemaId" or "recordKey")) + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); + return; + } + + var refused = wire.Sent.Single(); + var error = capable ? AssertRefused(refused, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)refused; + Assert.That(error.Code, Is.EqualTo("message_invalid")); + Assert.That(error.Details!.Field, Is.EqualTo("message")); + Assert.That(error.Error, Is.EqualTo(UnreadableVectorText())); + Assert.That(rig.TextOf(KeyA), Is.Null, "a write that names nothing is never applied"); + } + + public static IEnumerable WhiteSpaceCodePoints() => + Vectors().GetProperty("blankFields").GetProperty("whiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!); + + public static IEnumerable NonWhiteSpaceCodePoints() => + Vectors().GetProperty("blankFields").GetProperty("notWhiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!); + + private static readonly string[] BlankRuleFields = + { "schemaId", "recordKey", "storedAt", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags" }; + + // Blank is the Unicode White_Space property in every runtime (write-receipt.md, Required fields), not whatever the + // platform's char.IsWhiteSpace says: each listed code point, alone or repeated, in each covered field is refused as + // unreadable and never applied; each listed non-White_Space code point (U+200B, U+FEFF, U+180E, ...) is a name. + private async Task AssertBlankRule(string hex, bool blank) + { + foreach (var field in BlankRuleFields) + foreach (var times in new[] { 1, 3 }) + { + var text = string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(Convert.ToInt32(hex, 16)), times)); + var rig = Build(); + var wire = new Wire(); + await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + var message = rig.PutOf("m1", KeyA, "one"); + var document = message.Document!; + switch (field) + { + case "schemaId": document.SchemaId = text; break; + case "recordKey": document.RecordKey = text; break; + case "storedAt": document.StoredAt = text; break; + case "sourceRuntimeId": document.SourceRuntimeId = text; break; + case "sourceAgentId": document.SourceAgentId = text; break; + case "sourceRole": document.SourceRole = text; break; + default: document.Tags = new[] { "a", text }; break; + } + var what = $"U+{hex} in {field} x{times}"; + + await rig.PutHandler(message, wire.Peer); + + if (blank) + { + var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid").ToV0(); + Assert.That(error.Details!.Field, Is.EqualTo("message"), what); + Assert.That(error.Error, Is.EqualTo(UnreadableVectorText()), what); + Assert.That(rig.TextOf(KeyA), Is.Null, $"{what}: never applied"); + } + else + { + AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey); + } + } + } + + [Test] + public async Task EveryUnicodeWhiteSpaceCodePointIsBlank([ValueSource(nameof(WhiteSpaceCodePoints))] string hex) => + await AssertBlankRule(hex, blank: true); + + [Test] + public async Task CodePointsOutsideWhiteSpaceNameSomething([ValueSource(nameof(NonWhiteSpaceCodePoints))] string hex) => + await AssertBlankRule(hex, blank: false); + + // A nil payload names no document either; it is refused like an empty one, never handed to the store. + [Test] + public async Task APutWithANilPayloadIsRefusedAsUnreadable([Values] bool capable) + { + var rig = Build(); + var wire = new Wire(); + if (capable) await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + var message = rig.PutOf("m1", KeyA, "one"); + message.Document!.Payload = null!; + + await rig.PutHandler(message, wire.Peer); + + var refused = wire.Sent.Single(); + var error = capable ? AssertRefused(refused, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)refused; + Assert.That(error.Error, Is.EqualTo(UnreadableVectorText())); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + // The fixed text is also what an undecodable frame is answered with, from the transport, not a decoder's wording. + [Test] + public async Task AnUnreadableSchemaFrameIsAnsweredWithTheFixedText() + { + var (_, wire) = await Capable(); + wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); + Assert.That(AssertRefused(wire.Sent.Single(), null, "message_invalid").Error, Is.EqualTo(UnreadableVectorText())); + } + [TestCase("", "k")] [TestCase(" ", "k")] [TestCase("note.v0", "")]