write-receipt: one unreadable text, one blank-field rule (both servers) #64

Merged
libby merged 9 commits from libby/write-receipt-text-blank-rules into main 2026-11-09 14:15:49 +00:00
13 changed files with 782 additions and 26 deletions

View file

@ -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": [

View file

@ -36,8 +36,8 @@ 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`.
- **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.
- 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.
@ -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

View file

@ -3122,3 +3122,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<u8> {
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<u8> {
#[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(())
}

View file

@ -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,

View file

@ -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.

View file

@ -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`);
}
}

View file

@ -49,6 +49,7 @@ export class WriteTracker {
readonly #windowMs: number;
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
readonly #writes = new Map<string, Entry>();
#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" });
}

View file

@ -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<string, unknown>, 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] }));
}
});

View file

@ -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])]));

View file

@ -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;
}

View file

@ -587,6 +587,13 @@ namespace GameCult.Networking
Details = new CultNetErrorDetails { Field = field }
};
/// <summary>
/// The refusal of a frame that does not read, or whose write names nothing (write-receipt.md, Fixed texts):
/// <c>message_invalid</c>, details <c>{ field: "message" }</c>, and the one fixed text. A decoder's own text never reaches the wire.
/// </summary>
public static CultNetErrorMessage ForUnreadableMessage() =>
ForMessageInvalid("message", "the message could not be read");
/// <summary>
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.

View file

@ -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)

View file

@ -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<string> 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<string> WhiteSpaceCodePoints() =>
Vectors().GetProperty("blankFields").GetProperty("whiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!);
public static IEnumerable<string> 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", "")]