Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/rs-receipt-key-tests-per-frame
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 8m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m0s

This commit is contained in:
Libby 2026-11-09 10:37:49 +00:00
commit 2f3b30ab85
7 changed files with 356 additions and 8 deletions

View file

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

View file

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

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.

View file

@ -1422,7 +1422,7 @@ function validateBinaryMessageRoot(
if (candidate.schemaVersion !== schemaVersion) {
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
}
if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) {
if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) {
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
}
@ -1460,8 +1460,13 @@ function validateRawDocumentRecord(
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
}
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
function isBlank(value: string): boolean {
return /^\p{White_Space}*$/u.test(value);
}
function requireNonEmptyString(input: unknown, instancePath: string): void {
if (typeof input !== "string" || input.trim().length === 0) {
if (typeof input !== "string" || isBlank(input)) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
}
}
@ -1477,7 +1482,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void
if (typeof input === "undefined" || input === null) {
return;
}
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) {
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
}
}

View file

@ -49,6 +49,7 @@ export class WriteTracker {
readonly #windowMs: number;
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
readonly #writes = new Map<string, Entry>();
#ended = false;
constructor(options: WriteTrackerOptions) {
this.#windowMs = options.windowMs;
@ -58,7 +59,11 @@ export class WriteTracker {
/** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */
send(messageId: string, atMs: number): void {
this.tick(atMs);
if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } });
if (this.#writes.has(messageId)) return;
const entry: Entry = { sentAtMs: atMs, state: { outcome: "pending" } };
this.#writes.set(messageId, entry);
// On a session that already ended nothing can answer this write.
if (this.#ended) this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
}
/** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */
@ -93,9 +98,10 @@ export class WriteTracker {
}
}
/** The session ended: every unanswered write settles unconfirmed. */
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
sessionEnd(atMs: number): void {
this.tick(atMs);
this.#ended = true;
for (const [messageId, entry] of this.#writes) {
if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
}

View file

@ -1,6 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
@ -195,6 +197,38 @@ test("the replay runner passes every vector against this runtime", () => {
assert.doesNotMatch(run.stdout, /^FAIL/m);
});
// A runner that skips what it cannot read passes a file it did not replay. It must fail instead.
test("the replay runner fails on an unknown event key and on a vector count the file does not declare", () => {
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const dir = mkdtempSync(join(tmpdir(), "receipt-vectors-"));
const runWith = (edit: (vectors: any) => void) => {
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
edit(vectors);
const file = join(dir, "vectors.json");
writeFileSync(file, JSON.stringify(vectors));
return spawnSync(process.execPath, [script, file], { encoding: "utf8" });
};
const typo = runWith((v) => { v.scenarios.cases[0].events[0] = { atMs: 0, sendfailed: "w1" }; });
assert.equal(typo.status, 1, typo.stdout);
assert.match(typo.stdout, /^FAIL scenario\/receipt_admits$/m);
const dropped = runWith((v) => { v.scenarios.cases.pop(); });
assert.equal(dropped.status, 1, dropped.stdout);
assert.match(dropped.stdout, /^FAIL counts$/m);
const undeclared = runWith((v) => { v.wire.push(v.wire[0]); });
assert.equal(undeclared.status, 1, undeclared.stdout);
});
test("a write sent after the session ended settles unconfirmed session_ended at once, and the earlier receipt stands", () => {
const settled: [string, WriteSettlement][] = [];
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
tracker.send("w1", 0);
tracker.frame(admitted("w1"), 5);
tracker.sessionEnd(40);
tracker.send("w2", 50);
assert.deepEqual(settled, [["w1", { outcome: "admitted" }], ["w2", { outcome: "unconfirmed", reason: "session_ended" }]]);
});
test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => {
const settled: [string, WriteSettlement][] = [];
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
@ -227,3 +261,22 @@ test("a write whose own send failed settles local_send_failed even when its wind
tracker.sendFailed("w1", 1000);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and
// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do.
test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => {
const put = (document: Record<string, unknown>, messageId = "p1") => ({
schemaVersion: "cultnet.document_put_raw.v0", messageId,
document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document },
});
for (const blank of ["\u0085", "  ", " "]) {
assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`);
}
for (const named of ["", "​", "\u001c", "᠎"]) {
parseCultNetMessage(put({}, named));
parseCultNetMessage(put({ recordKey: named }));
parseCultNetMessage(put({ tags: [named] }));
}
});

View file

@ -23,6 +23,9 @@ const {
const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
// The event actions scenarios.semantics names; anything else is refused, not skipped.
const EVENT_KEYS = ["send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck"];
const results = [];
const check = (name, run) => {
try {
@ -79,10 +82,13 @@ for (const scenario of vectors.scenarios.cases) {
told.set(id, settlement);
} });
for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) {
const actions = Object.keys(event).filter((key) => key !== "atMs");
if (actions.length !== 1 || !EVENT_KEYS.includes(actions[0])) { problems.push(`event at ${event.atMs} has keys [${actions}], wanted atMs and exactly one of ${EVENT_KEYS}`); continue; }
if ("send" in event) tracker.send(event.send, event.atMs);
else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs);
else if ("tick" in event) tracker.tick(event.atMs);
else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs);
else if ("sendFailed" in event) tracker.sendFailed(event.sendFailed, event.atMs);
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
}
for (const [id, want] of Object.entries(scenario.expect)) {
@ -99,6 +105,16 @@ for (const scenario of vectors.scenarios.cases) {
});
}
// A runner that read fewer vectors than the file declares has silently skipped some. (contentHash is not replayed here.)
check("counts", () => {
const ran = (prefix) => results.filter((r) => r.name.startsWith(prefix)).length;
const loaded = { wire: vectors.wire.length, schemaInvalid: vectors.schemaInvalid.length, scenarioCases: vectors.scenarios.cases.length };
const executed = { wire: ran("wire/"), schemaInvalid: ran("schemaInvalid/"), scenarioCases: ran("scenario/") };
return Object.keys(executed)
.filter((key) => loaded[key] !== vectors.counts[key] || executed[key] !== vectors.counts[key])
.map((key) => `${key}: declared ${vectors.counts[key]}, loaded ${loaded[key]}, ran ${executed[key]}`);
});
function sorted(value) {
if (Array.isArray(value)) return value.map(sorted);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])]));