Merge write-receipt-text-blank-rules (3b1b7697) into libby/write-receipt-kt-interop
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s

This commit is contained in:
Libby 2026-11-09 07:38:23 +00:00
commit 80f1cfa385
5 changed files with 150 additions and 11 deletions

View file

@ -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, U+180E and U+001C to U+001F are not White_Space, though some platforms' whitespace tests accept some of them). Fields not listed are valid. 'unset' means the key is absent or nil, which is not blank: those cases are admitted.",
"validPutPayloadHex": "81A16101",
"whiteSpaceCodePoints": ["0009","000A","000B","000C","000D","0020","0085","00A0","1680","2000","2001","2002","2003","2004","2005","2006","2007","2008","2009","200A","2028","2029","202F","205F","3000"],
"notWhiteSpaceCodePoints": ["200B","200C","200D","2060","FEFF","180E","001C","001D","001E","001F"],
"codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string is not blank and the put is admitted.",
"cases": [
{ "name": "valid_document_is_admitted", "set": {}, "outcome": "admitted" },
{ "name": "empty_schema_id", "set": { "schemaId": "" }, "outcome": "refused" },
@ -126,6 +129,21 @@
{ "name": "blank_source_agent_id", "set": { "sourceAgentId": " " }, "outcome": "refused" },
{ "name": "blank_source_role", "set": { "sourceRole": "" }, "outcome": "refused" },
{ "name": "blank_tag", "set": { "tags": ["a", " "] }, "outcome": "refused" },
{ "name": "nbsp_schema_id", "set": { "schemaId": "\u00a0" }, "outcome": "refused" },
{ "name": "em_space_record_key", "set": { "recordKey": "\u2003\u2003" }, "outcome": "refused" },
{ "name": "next_line_stored_at", "set": { "storedAt": "\u0085" }, "outcome": "refused" },
{ "name": "line_separator_source_runtime_id", "set": { "sourceRuntimeId": "\u2028" }, "outcome": "refused" },
{ "name": "ideographic_space_source_agent_id", "set": { "sourceAgentId": "\u3000" }, "outcome": "refused" },
{ "name": "nbsp_source_role", "set": { "sourceRole": " \u00a0 " }, "outcome": "refused" },
{ "name": "em_space_tag", "set": { "tags": ["a", "\u2003"] }, "outcome": "refused" },
{ "name": "next_line_tag", "set": { "tags": ["\u0085"] }, "outcome": "refused" },
{ "name": "zero_width_space_schema_id_is_a_name", "set": { "schemaId": "\u200b" }, "outcome": "admitted" },
{ "name": "zero_width_space_record_key_is_a_name", "set": { "recordKey": "\u200b" }, "outcome": "admitted" },
{ "name": "zero_width_space_tag_is_a_name", "set": { "tags": ["\u200b"] }, "outcome": "admitted" },
{ "name": "bom_stored_at_is_a_name", "set": { "storedAt": "\ufeff" }, "outcome": "admitted" },
{ "name": "mongolian_vowel_separator_source_role_is_a_name", "set": { "sourceRole": "\u180e" }, "outcome": "admitted" },
{ "name": "padded_name_is_a_name", "set": { "schemaId": "\u00a0a\u2003", "tags": [" a"] }, "outcome": "admitted" },
{ "name": "file_separator_schema_id_is_a_name", "set": { "schemaId": "\u001c" }, "outcome": "admitted" },
{ "name": "source_ids_and_tags_present_and_named", "set": { "sourceRuntimeId": "r", "sourceAgentId": "a", "sourceRole": "w", "tags": ["a"] }, "outcome": "admitted" },
{ "name": "source_ids_and_tags_nil", "set": { "sourceRuntimeId": null, "sourceAgentId": null, "sourceRole": null, "tags": null }, "outcome": "admitted" },
{ "name": "empty_tag_list", "set": { "tags": [] }, "outcome": "admitted" }

View file

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

View file

@ -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<u8> {
blank_field_put_with_id(vectors, set, "m1")
}
fn blank_field_put_with_id(vectors: &serde_json::Value, set: &serde_json::Value, message_id: &str) -> Vec<u8> {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Put {
schema_version: &'static str,
message_id: &'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<()> {

View file

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

View file

@ -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<string> WhiteSpaceCodePoints() =>
Vectors().GetProperty("blankFields").GetProperty("whiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!);
public static IEnumerable<string> NonWhiteSpaceCodePoints() =>
Vectors().GetProperty("blankFields").GetProperty("notWhiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!);
private static readonly string[] BlankRuleFields =
{ "schemaId", "recordKey", "storedAt", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags" };
// Blank is the Unicode White_Space property in every runtime (write-receipt.md, Required fields), not whatever the
// platform's char.IsWhiteSpace says: each listed code point, alone or repeated, in each covered field is refused as
// unreadable and never applied; each listed non-White_Space code point (U+200B, U+FEFF, U+180E, ...) is a name.
private async Task AssertBlankRule(string hex, bool blank)
{
foreach (var field in BlankRuleFields)
foreach (var times in new[] { 1, 3 })
{
var text = string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(Convert.ToInt32(hex, 16)), times));
var rig = Build();
var wire = new Wire();
await rig.Hello(wire, BothFrames);
wire.Sent.Clear();
var message = rig.PutOf("m1", KeyA, "one");
var document = message.Document!;
switch (field)
{
case "schemaId": document.SchemaId = text; break;
case "recordKey": document.RecordKey = text; break;
case "storedAt": document.StoredAt = text; break;
case "sourceRuntimeId": document.SourceRuntimeId = text; break;
case "sourceAgentId": document.SourceAgentId = text; break;
case "sourceRole": document.SourceRole = text; break;
default: document.Tags = new[] { "a", text }; break;
}
var what = $"U+{hex} in {field} x{times}";
await rig.PutHandler(message, wire.Peer);
if (blank)
{
var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid").ToV0();
Assert.That(error.Details!.Field, Is.EqualTo("message"), what);
Assert.That(error.Error, Is.EqualTo(UnreadableVectorText()), what);
Assert.That(rig.TextOf(KeyA), Is.Null, $"{what}: never applied");
}
else
{
AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey);
}
}
}
[Test]
public async Task EveryUnicodeWhiteSpaceCodePointIsBlank([ValueSource(nameof(WhiteSpaceCodePoints))] string hex) =>
await AssertBlankRule(hex, blank: true);
[Test]
public async Task CodePointsOutsideWhiteSpaceNameSomething([ValueSource(nameof(NonWhiteSpaceCodePoints))] string hex) =>
await AssertBlankRule(hex, blank: false);
// A nil payload names no document either; it is refused like an empty one, never handed to the store.
[Test]
public async Task APutWithANilPayloadIsRefusedAsUnreadable([Values] bool capable)