From e99e9d4bc0824c7ff9f7317272e31be6fe7a008c Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 21:26:49 +0000 Subject: [PATCH 01/15] write-receipt spec: one unreadable text, one blank-field rule; Rust server tests pin both The unreadable-frame text is 'message_invalid: the message could not be read' (the servers' wording; the Rust wire never carried decoder text, only its local rejection display). A put that names nothing (blank schemaId/recordKey/storedAt, empty payload, blank present source ids or tags) is refused as unreadable on every connection; the vectors' blankFields section lists the cases and the Rust server tests replay them. Co-Authored-By: Claude Sonnet 5.5 --- .../interop/write-receipt-vectors.json | 29 +++- contracts/cultnet/interop/write-receipt.md | 6 +- .../cultmesh-rs/tests/rudp_document_server.rs | 134 ++++++++++++++++++ packages/cultnet-rs/tests/write_receipt.rs | 2 +- 4 files changed, 163 insertions(+), 8 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index c0313569..1dc39fd8 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,27 @@ } } ], + "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 only whitespace. Fields not listed are valid. 'unset' means the key is absent or nil, which is not blank: those cases are admitted.", + "validPutPayloadHex": "81A16101", + "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": "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", diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 8581cc53..35756ecf 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -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 empty or only whitespace; on a put's document, also a `storedAt` that is empty or only whitespace, 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 empty or only whitespace, and a `tags` entry that is empty or only whitespace. 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. @@ -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/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index cccff92f..b8772eaf 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3122,3 +3122,137 @@ 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 { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Put { + schema_version: &'static str, + message_id: &'static str, + 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: "m1", 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: "note.v0".into(), + record_key: "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(()) +} + +/// 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/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, -- 2.47.3 From bc9a1842f5587495aa03e6636991c7f7e3c12b15 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 21:26:49 +0000 Subject: [PATCH 02/15] C# database server: refuse a put that names nothing, one unreadable text IsUnreadableDocument follows the spec's Required fields rule (blank storedAt, empty payload, blank source ids and tags join blank schemaId/recordKey); ForUnreadableMessage is the single owner of the fixed text. Tests replay the blankFields vectors on receipt and legacy connections. Co-Authored-By: Claude Sonnet 5.5 --- .../CultNetDatabaseServer.cs | 25 ++++-- .../CultNetSchemaMessages.cs | 7 ++ src/GameCult.Networking/CultNetTransport.cs | 2 +- .../CultNetWriteReceiptServerTests.cs | 79 +++++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 1746391e..31822ade 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -390,15 +390,24 @@ 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); - private static CultNetErrorMessage UnreadableWriteTarget() => - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"); + private static bool IsUnreadableDocument(CultNetRawDocumentRecord document) => + IsUnreadableWriteTarget(document.SchemaId, document.RecordKey) + || string.IsNullOrWhiteSpace(document.StoredAt) + || document.Payload == null || document.Payload.Length == 0 + || IsPresentButBlank(document.SourceRuntimeId) + || IsPresentButBlank(document.SourceAgentId) + || IsPresentButBlank(document.SourceRole) + || document.Tags != null && document.Tags.Any(tag => string.IsNullOrWhiteSpace(tag)); + private static bool IsPresentButBlank(string? value) => value != null && string.IsNullOrWhiteSpace(value); + + // 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 +448,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 +531,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..23a7239c 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,83 @@ 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"); + 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"); + } + + // 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", "")] -- 2.47.3 From 98989f90ea45c6df9a2434055fb1a3abb6f627d7 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 21:57:38 +0000 Subject: [PATCH 03/15] C# tests: a nil payload names nothing (mutant survivor) Co-Authored-By: Claude Sonnet 5.5 --- .../CultNetWriteReceiptServerTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index 23a7239c..1ccd6c20 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -1101,6 +1101,25 @@ namespace GameCult.Networking.Tests Assert.That(rig.TextOf(KeyA), Is.Null, "a write that names nothing is never applied"); } + // 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() -- 2.47.3 From ccbe392b2870db5570ac821fbd125c46fbe7a26e Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:02:10 +0000 Subject: [PATCH 04/15] write-receipt vectors and tests: blank is Unicode White_Space, every code point checked Co-Authored-By: Claude Sonnet 5.5 --- .../interop/write-receipt-vectors.json | 18 +++++- .../cultmesh-rs/tests/rudp_document_server.rs | 60 ++++++++++++++++-- .../CultNetWriteReceiptServerTests.cs | 63 ++++++++++++++++++- 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index 1dc39fd8..1d83ea89 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -111,8 +111,11 @@ } ], "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 only whitespace. Fields not listed are valid. 'unset' means the key is absent or nil, which is not blank: those cases are admitted.", + "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 and U+180E are not White_Space). 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"], + "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" }, @@ -126,6 +129,19 @@ { "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": "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" } diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index b8772eaf..be667e72 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3139,11 +3139,15 @@ fn unreadable_vector_text(vectors: &serde_json::Value) -> 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: &'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()); @@ -3160,7 +3164,7 @@ fn blank_field_put(vectors: &serde_json::Value, set: &serde_json::Value) -> Vec< 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: "m1", document }).unwrap() + 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 @@ -3188,8 +3192,8 @@ fn a_put_that_names_nothing_is_refused_as_unreadable_naming_the_write_and_a_name vec![CultNetMessage::WriteAdmitted { message_id: "m1".into(), operation: "put".into(), - schema_id: "note.v0".into(), - record_key: "note:1".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}" ); @@ -3207,6 +3211,54 @@ fn a_put_that_names_nothing_is_refused_as_unreadable_naming_the_write_and_a_name 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<()> { diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index 1ccd6c20..e42961a2 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -1089,7 +1089,8 @@ namespace GameCult.Networking.Tests { if (capable) AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey); else Assert.That(wire.Sent, Is.Empty, "a legacy connection is sent no receipt"); - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); + if (!vectors.GetProperty("set").EnumerateObject().Any(f => f.Name is "schemaId" or "recordKey")) + Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); return; } @@ -1101,6 +1102,66 @@ namespace GameCult.Networking.Tests 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) -- 2.47.3 From db5e8834f38846f41107281339f5d8a011ef89ad Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:02:49 +0000 Subject: [PATCH 05/15] write-receipt: blank is exactly Unicode White_Space; C# server checks the listed set in one place Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- src/GameCult.Networking/CultNetDatabaseServer.cs | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 35756ecf..a3ad68dc 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,7 +36,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. 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 empty or only whitespace; on a put's document, also a `storedAt` that is empty or only whitespace, 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 empty or only whitespace, and a `tags` entry that is empty or only whitespace. 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. + - **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 and U+180E are not White_Space: 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. diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 31822ade..e1694131 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -394,18 +394,26 @@ namespace GameCult.Networking // (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 bool IsUnreadableDocument(CultNetRawDocumentRecord document) => IsUnreadableWriteTarget(document.SchemaId, document.RecordKey) - || string.IsNullOrWhiteSpace(document.StoredAt) + || 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(tag => string.IsNullOrWhiteSpace(tag)); + || document.Tags != null && document.Tags.Any(IsBlank); - private static bool IsPresentButBlank(string? value) => value != null && string.IsNullOrWhiteSpace(value); + 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) -- 2.47.3 From 52204c68c9c779bc6d14f7c276a82d9f4630c095 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:26:28 +0000 Subject: [PATCH 06/15] write-receipt vectors: U+001C to U+001F and padded names are not blank Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt-vectors.json | 6 ++++-- contracts/cultnet/interop/write-receipt.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index 1d83ea89..50e8a20f 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -111,10 +111,10 @@ } ], "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 and U+180E are not White_Space). Fields not listed are valid. 'unset' means the key is absent or nil, which is not blank: those cases are admitted.", + "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"], + "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" }, @@ -142,6 +142,8 @@ { "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" } diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index a3ad68dc..4ef863e4 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,7 +36,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. 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 and U+180E are not White_Space: 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. + - **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 (.NET's `char.IsWhiteSpace` accepts U+001C to U+001F, so a runtime on it must check the list explicitly): 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. -- 2.47.3 From 3b1b769762c9e7d966214fa095654eb68091d8b8 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:27:17 +0000 Subject: [PATCH 07/15] write-receipt spec: name the platforms that disagree accurately Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 4ef863e4..d3dc2614 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,7 +36,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. 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 (.NET's `char.IsWhiteSpace` accepts U+001C to U+001F, so a runtime on it must check the list explicitly): 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. + - **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. -- 2.47.3 From 4f6ee0db835aa1e6b7a5ce6d7e175ea463e75792 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 21:22:55 +0000 Subject: [PATCH 08/15] Rust RUDP server: pin the refusal of blank schemaId/recordKey on receipt sessions heimdall-low-rust-keys-untested. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/tests/rudp_document_server.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index be667e72..e37ce9d6 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -2983,6 +2983,75 @@ 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(()) +} + /// 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] -- 2.47.3 From bd2aff8ed0cdd76d6acc673dff5a0fea8fc10d72 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 21:23:07 +0000 Subject: [PATCH 09/15] Rust RUDP server: decide the sequenced-channel refusal per frame A sequenced packet's arrival can release held ordered frames in the same delivery; refuse only the packet's own frame. heimdall-low-refuse-sequenced-per-frame. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 4 +- .../cultmesh-rs/tests/rudp_document_server.rs | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index f97b2578..12fdf3e6 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -825,7 +825,9 @@ where 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 && frame.sequence <= packet.sequence { if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = self.refuse_unreliable_write(key, &frame.payload)? { diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index e37ce9d6..b6663396 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3052,6 +3052,51 @@ fn a_write_whose_schema_id_or_record_key_names_nothing_is_refused_and_never_hand 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(()) +} + /// 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] -- 2.47.3 From 4e89f07bff194851d518c7cb921b27a8f3fe375a Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:13:24 +0000 Subject: [PATCH 10/15] Rust RUDP server: answer a superseded sequenced write; order frame and packet by serial arithmetic Superseded packet: a reliable-sequenced packet that arrives after a newer one delivers no frame, yet is acknowledged and never resent, so its write went unanswered. The test (red first: only "new" was refused, "old" got nothing) showed the drop happens in the session below frame decoding, but an unfragmented packet carries its whole frame, so the server can read the id from the packet payload. Chosen: refuse it from the packet payload. A superseded fragment of a larger write has no readable id in the packet, so write-receipt.md now says that write may go unanswered and the client's unconfirmed outcome covers it. Ordering: frame.sequence <= packet.sequence is replaced by sequence_at_or_before, serial arithmetic modulo 2^32. Deviation from the brief: cultnet-rs' at_or_before carries a 4096-sequence receive window made for duplicate detection; a frame's first fragment may lie further back than that, so reusing it would leave such a sequenced write unrefused and applied. The new test pins that, and a hand mutant with the window fails it. A wrapped sequence cannot reach this compare through the session today (it is judged a duplicate), so the unit test pins the helper, not an end-to-end wrap. Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- .../cultmesh-rs/src/rudp_document_server.rs | 33 +++++- .../cultmesh-rs/tests/rudp_document_server.rs | 102 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index d3dc2614..b74411ea 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -38,7 +38,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. 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. + - **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. - 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. diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 12fdf3e6..a0ab51ac 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -821,13 +821,26 @@ 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 + && 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; } // `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 && frame.sequence <= packet.sequence { + if refuse_sequenced && sequence_at_or_before(frame.sequence, packet.sequence) { if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = self.refuse_unreliable_write(key, &frame.payload)? { @@ -1816,6 +1829,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")); @@ -1931,4 +1951,15 @@ 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)); + } } diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index b6663396..d9b42abb 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3097,6 +3097,108 @@ fn a_sequenced_packet_releasing_a_held_ordered_write_refuses_only_its_own() -> R 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(()) +} + +/// 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] -- 2.47.3 From 34cceedf9841b57f9bc01c7bad5e43199ec7e2ff Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 22:15:34 +0000 Subject: [PATCH 11/15] Rust RUDP server: pin the superseded-write refusal against a releasing packet and the half-space bound cargo-mutants on the previous commit missed two mutants: `&&` to `||` in the superseded-packet check (a packet that also releases another write hid it) and `<` to `<=` at the half-space bound of sequence_at_or_before. A test and two assertions kill them; the rerun catches 14 of 15 (1 unviable). Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 2 + .../cultmesh-rs/tests/rudp_document_server.rs | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index a0ab51ac..1e729407 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -1961,5 +1961,7 @@ mod tests { 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 d9b42abb..6ca0007a 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3138,6 +3138,50 @@ fn a_superseded_sequenced_write_is_still_refused() -> Result<()> { 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] -- 2.47.3 From 6c441d1cda7780a90ecbff2dacaa3f0e7ac7e542 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 09:12:59 +0000 Subject: [PATCH 12/15] Rust RUDP server: answer a sequenced write once, not once per send A repeat of a packet the session already took, and a packet too far ahead to be remembered, delivered no frame and so drew the superseded-write refusal again on every send, breaking write-receipt rule 1. The refusal now applies only to the first packet the session takes for a sequence; CultNetRudpSession::has_received exposes that test. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmesh-rs/src/rudp_document_server.rs | 9 +++ .../cultmesh-rs/tests/rudp_document_server.rs | 58 +++++++++++++++++++ packages/cultnet-rs/src/rudp.rs | 6 ++ 3 files changed, 73 insertions(+) diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 1e729407..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 @@ -825,6 +832,8 @@ where // 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) { diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 6ca0007a..60596ee5 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3138,6 +3138,64 @@ fn a_superseded_sequenced_write_is_still_refused() -> Result<()> { 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] 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 -- 2.47.3 From 636b387cf10550fcb335344f679f3f1879d6e8c8 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 09:13:31 +0000 Subject: [PATCH 13/15] write-receipt spec: a repeated or unreceived sequenced packet draws no second answer Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index b74411ea..234fdf93 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -38,7 +38,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. 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. + - **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. -- 2.47.3 From b082101c784f44a9910222f95f56ffe4cfb38ee9 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 10:22:15 +0000 Subject: [PATCH 14/15] cultnet-ts: a blank text in a raw frame is Unicode White_Space, not trim() validateBinaryMessageRoot, requireNonEmptyString and requireOptionalStringArray used String.prototype.trim(), which takes U+FEFF and misses U+0085, so the TS decoder and the servers disagreed on what names nothing (write-receipt rule 5). Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-ts/src/contracts.ts | 11 ++++++++--- .../cultnet-ts/test/write-receipt.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) 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/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 83c31411..efab1c28 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -227,3 +227,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] })); + } +}); -- 2.47.3 From f0c0f936ba037504d09960d2d83ab9516074ad06 Mon Sep 17 00:00:00 2001 From: Libby Date: Mon, 9 Nov 2026 10:34:49 +0000 Subject: [PATCH 15/15] write-receipt: seven tracker vectors, a tracker that ends with its session, runners that count New scenario vectors: id-less error.v1 with one and with two writes unanswered, sendFailed, an id-less error after the window, a receipt one ms inside the window and at the exact boundary (late), a send after sessionEnd. The last exposed a TS tracker leaving such a write pending forever; it now settles session_ended. The file declares counts, and the TS replay runner fails on a count it did not run or an event key scenarios.semantics does not name. Co-Authored-By: Claude Sonnet 5.5 --- .../interop/write-receipt-vectors.json | 269 +++++++++++++++++- contracts/cultnet/interop/write-receipt.md | 4 +- packages/cultnet-ts/CHANGELOG.md | 1 + packages/cultnet-ts/src/write-tracker.ts | 10 +- .../cultnet-ts/test/write-receipt.test.ts | 34 +++ .../tools/replay-write-receipt-vectors.mjs | 16 ++ 6 files changed, 329 insertions(+), 5 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index 50e8a20f..397fbad6 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -254,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", @@ -909,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" + } + } } ] }, @@ -917,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 d3dc2614..b01e69fb 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -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. 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/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 efab1c28..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]) }); 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])])); -- 2.47.3