TS: write_admitted / error.v1 decoder, WriteTracker, vector replay runner #62

Merged
libby merged 10 commits from libby/ts-write-receipt-client into main 2026-11-08 19:52:38 +00:00
17 changed files with 1110 additions and 83 deletions

View file

@ -61,7 +61,7 @@ A write settles as exactly one of:
## Changes to what #55 to #58 shipped
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so; the C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
## Vectors

View file

@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged.
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published |
| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) |
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |

View file

@ -5,8 +5,13 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
## Unreleased
### Added
- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed.
### Breaking
- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default.
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.

View file

@ -20,6 +20,10 @@ import {
CultNetRudpSocketTransportConnection,
rudpClientBindHost,
CultNetSchemaCatalog,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
WriteTracker,
helloAdvertisesWriteReceipts,
resultWindowMs,
cultNetMessageInvalidError,
cultNetTextError,
cultNetWriteNotAuthorizedError,
@ -44,6 +48,8 @@ import {
type CultNetSchemaCatalogOptions,
type CultNetSnapshotResponseRawMessage,
type CultNetWireContract,
type WriteSettlement,
type WriteUnconfirmedReason,
} from "@gamecult/cultnet-ts";
import {
@ -4189,8 +4195,8 @@ export interface CultMeshRudpDocumentServer {
*/
export class CultMeshRudpWriteRefusedError extends Error {
readonly code: string | undefined;
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>;
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>) {
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>;
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>) {
super(response.error);
this.name = "CultMeshRudpWriteRefusedError";
this.code = response.code ?? undefined;
@ -4198,14 +4204,49 @@ export class CultMeshRudpWriteRefusedError extends Error {
}
}
/**
* Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the
* session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted.
*/
export class CultMeshRudpWriteUnconfirmedError extends Error {
readonly reason: WriteUnconfirmedReason;
readonly messageId: string;
constructor(messageId: string, reason: WriteUnconfirmedReason) {
super(`Write ${messageId} was not confirmed: ${reason}.`);
this.name = "CultMeshRudpWriteUnconfirmedError";
this.reason = reason;
this.messageId = messageId;
}
}
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
/**
* How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not
* admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no
* success frame, so the put is reported published once this window passes with no refusal; a success reply
* ends the wait early. Default 500 ms.
* How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not
* admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the
* write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never
* published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also
* bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is
* unconfirmed as `local_send_failed`.
*/
resultWindowMs?: number;
/**
* Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1`
* before the put. A server that lists both back is receipt-capable: the put then resolves only on its
* `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with
* `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers
* as it always did.
*/
writeReceipts?: boolean;
/** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */
helloWaitMs?: number;
/**
* Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the
* transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError`
* (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or
* refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with
* `writeReceipts`.
*/
sendOnly?: boolean;
messageId?: string;
sourceRuntimeId?: string;
sourceAgentId?: string;
@ -6175,44 +6216,88 @@ export class CultMesh {
recordKey: string,
value: CultCacheDocumentValue<TDefinition>,
options: CultMeshRudpDocumentPublishOptions = {},
): Promise<void> {
): Promise<"admitted" | "sent"> {
requireNonEmpty(runtimeId, "runtimeId");
requireNonEmpty(recordKey, "recordKey");
if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined.");
const registry = new CultNetDocumentRegistry([binding]);
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
// Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause.
const put = registry.createRawDocumentPutMessage(
binding,
messageId,
recordKey,
value,
{
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
sourceAgentId: options.sourceAgentId,
sourceRole: options.sourceRole,
tags: options.tags,
},
);
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
try {
// A transport ack is not admission: the put completes on its result frame. A refusal rejects with the
// typed error whether it arrives before the ack, after it, or inside the result window.
let refusal: CultMeshRudpWriteRefusedError | undefined;
let succeeded = false;
if (options.sendOnly) {
try {
peer.send(put);
await peer.flush(options.flushTimeoutMs ?? 30_000);
} catch {
throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed");
}
return "sent";
}
// A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides
// how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw.
let receiptCapable = false;
let tracker: WriteTracker | undefined;
let settlement: WriteSettlement | undefined;
let wake: (() => void) | undefined;
let serverHello: (() => void) | undefined;
peer.on("message", (message: CultNetMessage) => {
if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message);
else if ("messageId" in message && message.messageId === messageId) succeeded = true;
else return;
wake?.();
if (message.schemaVersion === "cultnet.hello.v0") {
receiptCapable = helloAdvertisesWriteReceipts(message);
serverHello?.();
} else {
tracker?.frame(message, Date.now());
}
});
peer.send(registry.createRawDocumentPutMessage(
binding,
messageId,
recordKey,
value,
{
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
sourceAgentId: options.sourceAgentId,
sourceRole: options.sourceRole,
tags: options.tags,
},
));
await peer.flush(options.flushTimeoutMs ?? 30_000);
if (!refusal && !succeeded) {
peer.on("close", () => tracker?.sessionEnd(Date.now()));
if (options.writeReceipts) {
const answered = new Promise<void>((resolve) => { serverHello = resolve; });
peer.sendHello({
schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts",
supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS],
});
let helloTimer: NodeJS.Timeout | undefined;
await Promise.race([answered, new Promise<void>((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]);
clearTimeout(helloTimer);
}
const writeTracker = new WriteTracker({
windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable),
onSettle: (_id, result) => { settlement = result; wake?.(); },
});
tracker = writeTracker;
writeTracker.send(messageId, Date.now());
try {
peer.send(put);
// The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure.
const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1)));
} catch {
// Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal.
writeTracker.sendFailed(messageId, Date.now());
}
while (!settlement) {
const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, options.resultWindowMs ?? 500);
const timer = setTimeout(resolve, remaining);
wake = () => { clearTimeout(timer); resolve(); };
});
writeTracker.tick(Date.now());
}
if (refusal) throw refusal;
if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response);
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
return "admitted";
} finally {
peer.close();
}

View file

@ -16,7 +16,7 @@ import {
encodeRudpPacket,
} from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError } from "../src/index";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
const isTheRefusal = (error: unknown): boolean =>
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
@ -100,7 +100,7 @@ for (const order of orders) {
});
}
test("publishRudpDocumentOnce still publishes a put the server admitted", async () => {
test("a put the server applied but did not answer is unconfirmed, never published", async () => {
const connectionId = nextConnection++;
const puts: string[] = [];
const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, {
@ -111,7 +111,10 @@ test("publishRudpDocumentOnce still publishes a put the server admitted", async
});
await server.start();
try {
await CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 });
assert.deepEqual(puts, ["tests:completion:a"]);
await assert.rejects(
CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }),
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
);
assert.deepEqual(puts, ["tests:completion:a"], "the server did apply it; this server just does not answer");
} finally { server.close(); }
});

View file

@ -21,7 +21,7 @@ import {
type CultNetMessage,
type CultNetSnapshotResponseRawMessage,
} from "@gamecult/cultnet-ts";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
// A record's payload as the bytes a peer would put on the wire.
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
@ -2968,25 +2968,30 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
try {
await server.start();
await CultMesh.publishRudpDocumentOnce(
"cultmesh-ts-muninn",
connectionId,
`rudp://127.0.0.1:${server.bind.port}`,
defineCultNetDocumentBinding({ definition: noteDocument }),
"note:odin",
{
noteId: "note:odin",
body: "pay respects once",
},
{
resendDelayMs: 25,
resendPollMs: 5,
maxFragmentBytes: 1024,
maxPendingReliablePackets: 16,
connectTimeoutMs: 1_000,
flushTimeoutMs: 25,
sourceRole: "test-provider",
},
// This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did.
await assert.rejects(
CultMesh.publishRudpDocumentOnce(
"cultmesh-ts-muninn",
connectionId,
`rudp://127.0.0.1:${server.bind.port}`,
defineCultNetDocumentBinding({ definition: noteDocument }),
"note:odin",
{
noteId: "note:odin",
body: "pay respects once",
},
{
resendDelayMs: 25,
resendPollMs: 5,
maxFragmentBytes: 1024,
maxPendingReliablePackets: 16,
connectTimeoutMs: 1_000,
flushTimeoutMs: 25,
resultWindowMs: 100,
sourceRole: "test-provider",
},
),
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
);
await waitFor(() => received !== undefined, "published RUDP document");

View file

@ -0,0 +1,243 @@
// Client side of contracts/cultnet/interop/write-receipt.md in the one-shot writer: with `writeReceipts` the put
// completes only on a write_admitted carrying its id; a refusal, a silent window and a legacy server each end as the
// spec says. Each test serves the client a fake RUDP peer that acknowledges the put and answers as scripted.
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import test from "node:test";
import { z } from "zod";
import { defineDocumentType } from "@gamecult/cultcache-ts";
import {
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
CultNetRudpSession,
decodeRudpPacket,
defineCultNetDocumentBinding,
encodeCultNetMessageForWire,
encodeRudpPacket,
type CultNetMessage,
} from "@gamecult/cultnet-ts";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
const note = defineDocumentType({
type: "cultmesh.receipt_note",
schemaId: "cultmesh.receipt_note.v0",
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const binding = defineCultNetDocumentBinding({ definition: note });
let nextConnection = 0x10206000;
interface Script {
/** Does the server's hello list both receipt frames? */
advertise: boolean;
/** What the server answers a put with, `delayMs` after it arrives. */
answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo";
delayMs?: number;
/** Acknowledge the reliable packets it receives? Default true. */
ack?: boolean;
}
async function fakeServer(connectionId: number, script: Script) {
const socket: Socket = createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
const timers = new Set<NodeJS.Timeout>();
const seen: string[] = [];
let closed = false;
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
};
const say = (message: CultNetMessage, port: number, address: string) => {
const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() });
packets.forEach((p) => send(p, port, address));
};
socket.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const now = Date.now();
if (packet.packetType === "connect") {
send(session.acceptConnect(packet, now), remote.port, remote.address);
return;
}
const result = session.receive(packet, now);
if (result.reply) send(result.reply, remote.port, remote.address);
for (const frame of result.delivered) {
if (frame.channelId !== "schema") continue;
const message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string };
seen.push(message.schemaVersion);
if (message.schemaVersion === "cultnet.hello.v0") {
say({
schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake",
...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}),
}, remote.port, remote.address);
continue;
}
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue;
const timer = setTimeout(() => {
timers.delete(timer);
if (script.answer === "echo") {
say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: message.messageId!, document: { schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } } as unknown as CultNetMessage, remote.port, remote.address);
} else if (script.answer === "goodbye") {
send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address);
} else if (script.answer === "refuse") {
say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address);
} else {
const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!;
say({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a" }, remote.port, remote.address);
}
}, script.delayMs ?? 20);
timers.add(timer);
}
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
}
const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 };
const publish = (server: { endpoint: string }, options: Record<string, unknown>) =>
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options });
test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 });
try {
await publish(server, { writeReceipts: true });
assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]);
} finally { server.close(); }
});
test("a receipt-capable server's error.v1 refuses the put with its code", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" });
try {
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1");
} finally { server.close(); }
});
test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
} finally { server.close(); }
});
test("a receipt for another id settles nothing", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError);
} finally { server.close(); }
});
test("a server whose hello does not advertise leaves a silent put unconfirmed after the 500 ms window, never published", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
assert.ok(Date.now() - started >= 500, "the legacy window ran from the send");
} finally { server.close(); }
});
test("a session that ends before any result leaves the put unconfirmed (session_ended), against a legacy server too", async () => {
for (const advertise of [false, true]) {
const server = await fakeServer(nextConnection, { advertise, answer: "goodbye" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 3_000 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "session_ended");
} finally { server.close(); }
}
});
test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
assert.ok(Date.now() - started < 2_500, "flushTimeoutMs bounds the send inside the 5 s window");
} finally { server.close(); }
});
test("the flush is bounded by the result window: a send nobody acknowledges is local_send_failed at the window, not at the flush timeout", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
const started = Date.now();
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300, flushTimeoutMs: 20_000 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
assert.ok(Date.now() - started < 3_000, "it did not wait out the 20 s flush timeout");
} finally { server.close(); }
});
test("a server that does not advertise receipts and echoes the put's id back has not admitted it", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "echo" });
try {
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
} finally { server.close(); }
});
test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => {
const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
await assert.rejects(publish(dflt, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError);
} finally { dflt.close(); }
const sendOnly = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
const outcome = await publish(sendOnly, { sendOnly: true });
assert.equal(outcome, "sent");
assert.notEqual(outcome, "admitted");
assert.deepEqual(sendOnly.seen, ["cultnet.document_put_raw.v0"], "sendOnly sends no hello");
} finally { sendOnly.close(); }
});
test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => {
const admitting = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
try {
assert.equal(await publish(admitting, { writeReceipts: true }), "admitted");
} finally { admitting.close(); }
const refusing = await fakeServer(nextConnection, { advertise: true, answer: "refuse", delayMs: 1 });
try {
assert.equal(await publish(refusing, { sendOnly: true }), "sent", "a refusal is not awaited: sendOnly says only that the put was sent");
} finally { refusing.close(); }
});
test("sendOnly fails local_send_failed when the send is never acknowledged", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
try {
await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) =>
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
} finally { server.close(); }
});
test("sendOnly keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, sendOnly: true }),
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
);
assert.deepEqual(server.seen, [], "nothing was sent");
} finally { server.close(); }
});
test("the receipt path keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, writeReceipts: true }),
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
);
assert.ok(!server.seen.includes("cultnet.document_put_raw.v0"), "no put was sent");
} finally { server.close(); }
});
test("sendOnly cannot be combined with writeReceipts", async () => {
await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError);
});
test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => {
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
try {
await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError);
assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]);
} finally { server.close(); }
});

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added
- 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.
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
@ -16,6 +17,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Breaking
- The Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success.
- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged.
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.

View file

@ -289,6 +289,33 @@ maps them back to the exact C# union payload shape at the wire boundary.
serve and dial commands also exercise schema-v0 over the shared RUDP socket
transport and advertise both transport profiles.
## Write receipt replay runner
`WriteTracker` is the client half of `contracts/cultnet/interop/write-receipt.md`: a pure state machine on an abstract
clock that owns one decision, how each sent write settles. `tools/replay-write-receipt-vectors.mjs` replays
`write-receipt-vectors.json` against it (build first, then `node tools/replay-write-receipt-vectors.mjs`): it prints
`PASS`/`FAIL` per case and exits nonzero on any failure. Rust and C# port the same runner by implementing this interface
and replaying the same file.
Tracker interface (times are integer milliseconds on the vector's clock; the constructor takes the window):
| Call | Meaning |
| --- | --- |
| `new WriteTracker({ windowMs, onSettle })` | `windowMs` = 5000 when the server's hello lists both `cultnet.write_admitted.v0` and `cultnet.error.v1`, else 500. `onSettle(id, settlement)` fires once per write. |
| `send(id, atMs)` | A write with that `messageId` was sent. Its window runs from here, not from any transport ack. |
| `frame(message, atMs)` | A decoded frame arrived. `write_admitted` and `error.v1` settle the write they name; `error.v0`, or `error.v1` with nil `messageId`, refuses a write only when exactly one is unanswered; an id not in flight settles nothing. |
| `tick(atMs)` | Advance the clock. Every unanswered write with `atMs - sentAt >= windowMs` settles `unconfirmed(window_elapsed)`. `send`, `frame` and `sessionEnd` tick first. |
| `sessionEnd(atMs)` | Every unanswered write settles `unconfirmed(session_ended)`. |
| `state(id)` | `pending`, or the settlement plus `recorded`: `admitted_late` / `refused_late`, set by the first answer that arrives after `unconfirmed`. A settlement never changes. |
Runner, per case in `scenarios.cases` (the transport ack is not an input: delivery is not an answer):
1. The fake server states its capability the only way a real one does, in a hello (`server: "receipt"` lists both frames, `"legacy"` lists none); the window comes from that hello and must equal `constants[case.window]`.
2. Events run in `atMs` order: `send` -> `send(id)`, `frame` -> encode the frame to MessagePack, decode and validate it as the client would, then `frame(message)`, `tick` -> `tick`, `sessionEnd` -> `sessionEnd`, `transportAck` -> nothing.
3. For each id in `expect`, the settlement given to the caller has the expected `outcome` (and `code` or `reason` when given), `state(id).recorded` equals the expected `recorded` (absent when absent), and nothing settled twice or outside `expect`.
The runner also decodes each `wire[].messagePackHex` and compares the fields and the re-encoded bytes to the vector, and requires every `schemaInvalid[]` frame to be refused by the decoder. `dedupe.cases` and `contentHash` are server vectors and not replayed here.
## Development
```bash

View file

@ -9,6 +9,8 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json";
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json";
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
@ -62,6 +64,8 @@ export type CultNetSchemaVersion =
| "cultnet.verify.v0"
| "cultnet.login_success.v0"
| "cultnet.error.v0"
| "cultnet.error.v1"
| "cultnet.write_admitted.v0"
| "cultnet.sample.change_name.v0"
| "cultnet.sample.chat.v0"
| "cultnet.document_put.v0"
@ -201,6 +205,11 @@ export const CULTNET_ERROR_CODES = [
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */
export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const;
export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_CODES)[number];
/** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */
export function cultNetTextError(error: string): CultNetErrorMessage {
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
@ -255,6 +264,34 @@ export interface CultNetErrorMessage {
details: CultNetErrorDetails | null;
}
/**
* cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a
* connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md).
*/
export interface CultNetErrorV1Message extends Omit<CultNetErrorMessage, "schemaVersion" | "code"> {
schemaVersion: "cultnet.error.v1";
code: CultNetErrorV1Code | null;
messageId: string | null;
}
/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */
export interface CultNetWriteAdmittedMessage {
schemaVersion: "cultnet.write_admitted.v0";
messageId: string;
operation: "put" | "delete";
schemaId: string;
recordKey: string;
}
/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */
export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const;
/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */
export function helloAdvertisesWriteReceipts(hello: Pick<CultNetHelloMessage, "supportedMessageVersions">): boolean {
const listed = hello.supportedMessageVersions ?? [];
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version));
}
export interface CultNetSampleChangeNameMessage {
schemaVersion: "cultnet.sample.change_name.v0";
name: string;
@ -770,6 +807,8 @@ export interface GhostlightAgentStateDocument {
export type CultNetMessage =
| CultNetHelloMessage
| CultNetErrorV1Message
| CultNetWriteAdmittedMessage
| CultNetLoginMessage
| CultNetRegisterMessage
| CultNetVerifyMessage
@ -812,6 +851,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
verifySchema,
loginSuccessSchema,
errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema,
sampleChatSchema,
documentPutSchema,
@ -1535,6 +1576,8 @@ export const cultNetSchemas = {
verifySchema,
loginSuccessSchema,
errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema,
sampleChatSchema,
documentPutSchema,

View file

@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher(
const publish = async (detail: string) => {
const signed = signer.sign(detail);
messageSequence += 1;
await publishDocument(endpoint, connectionId, {
await sendPresenceDocument(endpoint, connectionId, {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`,
document: signed.document,
@ -86,11 +86,19 @@ export function createIdunnRuntimePresencePublisher(
};
}
async function publishDocument(
/**
* Sends one presence put and says only that it was sent (write-receipt.md: the explicit send-only mode). Odin does not
* answer a write with a receipt yet, so no frame, whatever id it carries, is admission; this never claims it. It returns
* once the transport has acknowledged the put, rejects `IdunnPresenceRefusedError` if a refusal arrives by then, and
* rejects if the put is never acknowledged.
*
* @internal exported for tests
*/
export async function sendPresenceDocument(
endpoint: { host: string; port: number },
connectionId: number,
message: Record<string, unknown>,
): Promise<void> {
): Promise<"sent"> {
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
@ -107,26 +115,24 @@ async function publishDocument(
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
for (const packet of packets) await sendPacket(socket, endpoint, packet);
// The transport ack is not admission: the put completes on Odin's result frame. A refusal rejects whether
// it arrives before the ack, after it, or inside the result window; a success reply ends the wait early.
let succeeded = false;
// The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything.
const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
if (frame.channelId !== "schema") return;
const response = parseCultNetMessage(decode(frame.payload));
if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
succeeded = true;
// An error.v1 refuses this put only when it names it (or names nothing).
if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) {
throw new IdunnPresenceRefusedError(response);
}
};
await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame);
if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true);
return "sent";
} finally {
receiver.close();
socket.close();
}
}
/** How long after the transport ack Odin's refusal may still arrive; a success reply ends the wait early. */
const RESULT_WINDOW_MS = 500;
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
export class IdunnPresenceRefusedError extends Error {
readonly code: string | undefined;
@ -200,7 +206,6 @@ async function receiveUntil(
predicate: (packet: CultNetRudpPacket) => boolean,
timeoutMs: number,
onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void,
returnOnTimeout = false,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@ -215,7 +220,6 @@ async function receiveUntil(
}
for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet);
}
if (returnOnTimeout) return;
throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`);
}

View file

@ -17,6 +17,12 @@ export {
type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
helloAdvertisesWriteReceipts,
type CultNetErrorV1Code,
type CultNetErrorV1Message,
type CultNetWriteAdmittedMessage,
cultNetTextError,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
@ -249,3 +255,15 @@ export {
type IdunnRuntimePresencePublisher,
type IdunnRuntimePresencePublisherOptions,
} from "./idunn-odin-presence-publisher";
export {
LEGACY_RESULT_WINDOW_MS,
RECEIPT_RESULT_WINDOW_MS,
WriteTracker,
resultWindowMs,
type WriteRecorded,
type WriteSettlement,
type WriteState,
type WriteTrackerOptions,
type WriteUnconfirmedReason,
} from "./write-tracker";

View file

@ -0,0 +1,144 @@
// The client half of the write receipt (contracts/cultnet/interop/write-receipt.md, "Client rules"): a pure state
// machine with an abstract clock. It owns exactly one decision, how each sent write settles. Transport, timers and
// frame decoding belong to the caller; the replay runner (tools/replay-write-receipt-vectors.mjs) drives this same
// interface from write-receipt-vectors.json.
import type { CultNetErrorMessage, CultNetErrorV1Message, CultNetMessage } from "./contracts";
/** The result window against a server whose hello lists both receipt frames. */
export const RECEIPT_RESULT_WINDOW_MS = 5000;
/** The result window against a server whose hello does not list them (or that sent none). */
export const LEGACY_RESULT_WINDOW_MS = 500;
export function resultWindowMs(serverAdvertisesReceipts: boolean): number {
return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS;
}
/**
* Why nothing settled a write: its window ran out, the session ended first, or the client's own send of it failed (it may or
* may not have left the machine, and is never a refusal).
*/
export type WriteUnconfirmedReason = "window_elapsed" | "session_ended" | "local_send_failed";
/** What a late answer is recorded as on a write that already settled unconfirmed. */
export type WriteRecorded = "admitted_late" | "refused_late";
/** What the caller was told. Settled once; never changed by a later frame. */
export type WriteSettlement =
| { outcome: "admitted" }
| { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message }
| { outcome: "unconfirmed"; reason: WriteUnconfirmedReason };
/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */
export type WriteState =
| { outcome: "pending"; recorded?: undefined }
| (WriteSettlement & { recorded?: WriteRecorded });
export interface WriteTrackerOptions {
/** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */
windowMs: number;
/** Called once per write, at the moment it settles. */
onSettle?: (messageId: string, settlement: WriteSettlement) => void;
}
interface Entry {
sentAtMs: number;
state: WriteState;
}
export class WriteTracker {
readonly #windowMs: number;
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
readonly #writes = new Map<string, Entry>();
constructor(options: WriteTrackerOptions) {
this.#windowMs = options.windowMs;
this.#onSettle = options.onSettle;
}
/** 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" } });
}
/** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */
frame(message: CultNetMessage, atMs: number): void {
this.tick(atMs);
switch (message.schemaVersion) {
case "cultnet.write_admitted.v0":
this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late");
return;
case "cultnet.error.v1":
if (message.messageId !== null) {
this.#answer(message.messageId, refusalOf(message), "refused_late");
return;
}
break;
case "cultnet.error.v0":
break;
default:
return;
}
// An error that names no write is attributable only when exactly one write is unanswered.
const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending");
if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message));
}
/** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */
tick(atMs: number): void {
for (const [messageId, entry] of this.#writes) {
if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) {
this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" });
}
}
}
/** The session ended: every unanswered write settles unconfirmed. */
sessionEnd(atMs: number): void {
this.tick(atMs);
for (const [messageId, entry] of this.#writes) {
if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
}
}
/** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */
sendFailed(messageId: string, atMs: number): void {
const entry = this.#writes.get(messageId);
if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" });
this.tick(atMs);
}
state(messageId: string): WriteState | undefined {
return this.#writes.get(messageId)?.state;
}
/** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */
nextDeadlineMs(atMs: number): number | undefined {
let next: number | undefined;
for (const entry of this.#writes.values()) {
if (entry.state.outcome !== "pending") continue;
const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs);
if (next === undefined || remaining < next) next = remaining;
}
return next;
}
#answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void {
const entry = this.#writes.get(messageId);
if (!entry) return; // names a write that is not in flight: settles nothing
if (entry.state.outcome === "pending") {
this.#settle(messageId, entry, settlement);
} else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) {
entry.state = { ...entry.state, recorded: late };
}
}
#settle(messageId: string, entry: Entry, settlement: WriteSettlement): void {
entry.state = settlement;
this.#onSettle?.(messageId, settlement);
}
}
function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement {
return { outcome: "refused", code: message.code, error: message.error, response: message };
}

View file

@ -0,0 +1,94 @@
// The presence publisher is send-only (write-receipt.md): Odin answers no write with a receipt yet, so no frame, whatever
// id it carries, is admission. Each test serves it a fake Odin that acknowledges the put and answers as scripted.
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import test from "node:test";
import { encode, decode } from "@msgpack/msgpack";
import { encodeCultNetMessageForWire, type CultNetMessage } from "../src/contracts";
import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-odin-presence-publisher";
import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp";
interface Script {
answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none";
/** Acknowledge the put? Default true. */
ack?: boolean;
}
async function fakeOdin(connectionId: number, script: Script) {
const socket: Socket = createSocket("udp4");
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ }
};
const say = (message: CultNetMessage, port: number, address: string) => {
const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() });
packets.forEach((p) => send(p, port, address));
};
socket.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const now = Date.now();
if (packet.packetType === "connect") {
send(session.acceptConnect(packet, now), remote.port, remote.address);
return;
}
const result = session.receive(packet, now);
if (result.reply) send(result.reply, remote.port, remote.address);
for (const frame of result.delivered) {
if (frame.channelId !== "schema") continue;
const put = decode(frame.payload) as { messageId: string; document: unknown };
if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address);
else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address);
else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address);
else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address);
else if (script.answer === "refuse-v1-same") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: put.messageId }, remote.port, remote.address);
else if (script.answer === "refuse-v1-none") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: null }, remote.port, remote.address);
}
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { port: socket.address().port, close: () => socket.close() };
}
let nextConnection = 0x0d1d1000;
const put = (id: string) => ({ schemaVersion: "cultnet.document_put_raw.v0", messageId: id, document: { schemaId: "s", recordKey: "k", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } });
const sendTo = (server: { port: number }, id: string, connectionId: number) =>
sendPresenceDocument({ host: "127.0.0.1", port: server.port }, connectionId, put(id));
for (const answer of ["silence", "echo", "admitted", "refuse-v1-other"] as const) {
test(`a ${answer} Odin yields sent, never admitted: no frame carrying the put's id is success`, async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer });
try {
const outcome = await sendTo(server, "presence-1", connectionId);
assert.equal(outcome, "sent");
assert.notEqual(outcome as string, "admitted");
} finally { server.close(); }
});
}
test("a refusal that arrives by the transport ack rejects the put", async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer: "refuse-v0" });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
} finally { server.close(); }
});
for (const answer of ["refuse-v1-same", "refuse-v1-none"] as const) {
test(`an error.v1 refusal that ${answer === "refuse-v1-same" ? "names this put" : "names no put"} rejects the put`, async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
} finally { server.close(); }
});
}
test("a put the transport never acknowledges is an error, not sent", async () => {
const connectionId = nextConnection++;
const server = await fakeOdin(connectionId, { answer: "silence", ack: false });
try {
await assert.rejects(sendTo(server, "presence-1", connectionId), /Timed out/);
} finally { server.close(); }
});

View file

@ -345,18 +345,27 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte
assert.equal(peer.failure, undefined, peer.failure?.message);
});
for (const order of ["ack-then-refusal", "refusal-then-ack"] as const) {
test(`the Odin publisher does not complete on the ack of a refused presence (${order})`, async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);
peer.reject = true;
peer.order = order;
const signer = signerFor(context, "service");
signer.reportHealth("active");
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/);
});
}
test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);
peer.reject = true;
peer.order = "refusal-then-ack";
const signer = signerFor(context, "service");
signer.reportHealth("active");
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/);
});
test("the Odin publisher is send-only: a refusal that follows the transport ack is not awaited", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;
const peer = await startOdinPeer(context, endpoint);
peer.reject = true;
peer.order = "ack-then-refusal";
const signer = signerFor(context, "service");
signer.reportHealth("active");
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
await publisher.publish("denied presence");
});
test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => {
const endpoint = expectedFacts("service").odinEndpoint!;

View file

@ -0,0 +1,229 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import {
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
LEGACY_RESULT_WINDOW_MS,
RECEIPT_RESULT_WINDOW_MS,
WriteTracker,
cultNetSchemas,
helloAdvertisesWriteReceipts,
parseCultNetMessage,
resultWindowMs,
type CultNetErrorV1Message,
type CultNetMessage,
type CultNetWriteAdmittedMessage,
type WriteSettlement,
} from "../src";
const admitted = (messageId: string): CultNetWriteAdmittedMessage =>
({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" });
const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message =>
({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId });
const errorV0 = (): CultNetMessage =>
({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null });
test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => {
const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null);
assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort());
for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code });
assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" }));
});
test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => {
assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1");
assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null);
const { messageId: _omitted, ...withoutId } = refusedV1("w1");
assert.throws(() => parseCultNetMessage(withoutId));
assert.throws(() => parseCultNetMessage(refusedV1("")));
});
test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => {
assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false);
assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" }));
assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" }));
assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0");
});
test("write_admitted needs all five keys, a known operation and no error key", () => {
parseCultNetMessage(admitted("w1"));
parseCultNetMessage({ ...admitted("w1"), operation: "delete" });
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" }));
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" }));
const { recordKey: _omitted, ...short } = admitted("w1");
assert.throws(() => parseCultNetMessage(short));
});
test("a hello is receipt-capable only when it lists both frames", () => {
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false);
assert.equal(helloAdvertisesWriteReceipts({}), false);
const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] });
assert.equal(hello.schemaVersion, "cultnet.hello.v0");
});
test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => {
assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS);
assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS);
assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000);
assert.equal(LEGACY_RESULT_WINDOW_MS, 500);
});
function tracker(windowMs = 500) {
const told: [string, WriteSettlement][] = [];
return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) };
}
test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => {
const { t, told } = tracker();
t.send("w1", 100);
t.tick(599);
assert.equal(t.state("w1")?.outcome, "pending");
t.tick(600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: each write's window runs from its own send", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 300);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
assert.equal(t.state("w2")?.outcome, "pending");
});
test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(admitted("w1"), 500);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" });
assert.equal(told.length, 1);
});
test("tracker: a write settles once; later frames neither change it nor flip a late record", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(refusedV1("w1"), 10);
t.frame(admitted("w1"), 20);
t.frame(refusedV1("w1", "message_invalid"), 30);
t.sessionEnd(40);
assert.equal(told.length, 1);
assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") });
t.send("w2", 100);
t.tick(600);
t.frame(refusedV1("w2"), 700);
t.frame(admitted("w2"), 800);
assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late");
});
test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 1);
t.frame(admitted("w1"), 5);
t.frame(refusedV1(null), 6);
assert.equal(t.state("w1")?.outcome, "admitted");
assert.equal(t.state("w2")?.outcome, "refused");
});
test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => {
const { t } = tracker();
t.send("w1", 0);
t.tick(500);
t.frame(errorV0(), 501);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
});
test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 0);
t.send("w3", 400);
t.frame(admitted("w2"), 10);
t.sessionEnd(600);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.equal(t.state("w2")?.outcome, "admitted");
assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" });
});
test("tracker: re-sending a known id is the same write and does not restart its window", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w1", 400);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
});
test("tracker: a send past another write's window settles that write at once", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.send("w2", 600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => {
const { t } = tracker();
t.send("w1", 0);
t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1);
t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2);
assert.equal(t.state("w1")?.outcome, "pending");
});
test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => {
const { t } = tracker();
assert.equal(t.nextDeadlineMs(0), undefined);
t.send("w1", 0);
t.send("w2", 100);
assert.equal(t.nextDeadlineMs(50), 450);
assert.equal(t.nextDeadlineMs(900), 0);
t.frame(admitted("w1"), 60);
assert.equal(t.nextDeadlineMs(150), 450);
t.frame(admitted("w2"), 160);
assert.equal(t.nextDeadlineMs(170), undefined);
});
test("the replay runner passes every vector against this runtime", () => {
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
const run = spawnSync(process.execPath, [script], { encoding: "utf8" });
assert.equal(run.status, 0, run.stdout + run.stderr);
assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m);
assert.doesNotMatch(run.stdout, /^FAIL/m);
});
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]) });
tracker.send("w1", 0);
tracker.send("w2", 0);
tracker.sendFailed("w1", 10);
assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]);
assert.equal(tracker.state("w2")?.outcome, "pending");
tracker.frame(admitted("w1"), 20);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" });
tracker.sendFailed("w2", 30);
tracker.sendFailed("w2", 31);
assert.equal(settled.length, 2, "a write settles once");
tracker.sendFailed("unknown", 40);
assert.equal(tracker.state("unknown"), undefined);
});
test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.send("w2", 500);
tracker.sendFailed("w2", 1200);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
test("a write whose own send failed settles local_send_failed even when its window has also run", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.sendFailed("w1", 1000);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
});

View file

@ -0,0 +1,116 @@
#!/usr/bin/env node
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: every `scenarios.cases` event
// replay through a fake server and the WriteTracker, and every `wire` / `schemaInvalid` frame through the real
// decoder. Prints PASS/FAIL per case; exit 1 on any failure. Runs against the built package (`npm run build` first).
//
// node tools/replay-write-receipt-vectors.mjs [path/to/write-receipt-vectors.json]
//
// The runner interface a port (Rust, C#) must reproduce is documented in README.md, "Write receipt replay runner".
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
const { decode, encode } = require("@msgpack/msgpack");
const {
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, LEGACY_RESULT_WINDOW_MS, RECEIPT_RESULT_WINDOW_MS, WriteTracker,
encodeCultNetMessageForWire, helloAdvertisesWriteReceipts, parseCultNetMessage, resultWindowMs,
} = require(path.join(here, "..", "dist", "index.js"));
const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
const results = [];
const check = (name, run) => {
try {
const problems = run();
results.push({ name, problems });
} catch (error) {
results.push({ name, problems: [`threw: ${error instanceof Error ? error.message : String(error)}`] });
}
};
const bytesOf = (hex) => Uint8Array.from(Buffer.from(hex, "hex"));
const hexOf = (bytes) => Buffer.from(bytes).toString("hex").toUpperCase();
// A frame reaches the client as bytes: the fake server encodes it, the client decodes and validates it.
const delivered = (frame) => parseCultNetMessage(decode(encode(frame)));
check("constants", () => [
...(vectors.constants.RECEIPT_RESULT_WINDOW_MS === RECEIPT_RESULT_WINDOW_MS ? [] : [`RECEIPT window ${RECEIPT_RESULT_WINDOW_MS} != ${vectors.constants.RECEIPT_RESULT_WINDOW_MS}`]),
...(vectors.constants.LEGACY_RESULT_WINDOW_MS === LEGACY_RESULT_WINDOW_MS ? [] : [`LEGACY window ${LEGACY_RESULT_WINDOW_MS} != ${vectors.constants.LEGACY_RESULT_WINDOW_MS}`]),
]);
for (const vector of vectors.wire) {
check(`wire/${vector.name}`, () => {
const problems = [];
const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
if (JSON.stringify(sorted(parsed)) !== JSON.stringify(sorted(vector.message))) problems.push(`decoded ${JSON.stringify(parsed)} != ${JSON.stringify(vector.message)}`);
const reencoded = hexOf(encode(encodeCultNetMessageForWire(parsed)));
if (reencoded !== vector.messagePackHex.toUpperCase()) problems.push(`re-encoded bytes ${reencoded} != captured ${vector.messagePackHex}`);
return problems;
});
}
for (const vector of vectors.schemaInvalid) {
check(`schemaInvalid/${vector.name}`, () => {
try {
parseCultNetMessage(decode(bytesOf(vector.messagePackHex)));
} catch {
return [];
}
return [`accepted a frame that must be refused (${vector.why})`];
});
}
for (const scenario of vectors.scenarios.cases) {
check(`scenario/${scenario.name}`, () => {
// The fake server states its capability the only way a real one does: in its hello.
const serverHello = { schemaVersion: "cultnet.hello.v0", runtimeId: "fake-server", runtimeKind: "fake",
...(scenario.server === "receipt" ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}) };
const windowMs = resultWindowMs(helloAdvertisesWriteReceipts(serverHello));
const problems = [];
if (windowMs !== vectors.constants[scenario.window]) problems.push(`window ${windowMs} != ${scenario.window}`);
const told = new Map(); // what the caller was told, at settle time
const tracker = new WriteTracker({ windowMs, onSettle: (id, settlement) => {
if (told.has(id)) problems.push(`${id} settled twice`);
told.set(id, settlement);
} });
for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) {
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);
// 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)) {
const got = told.get(id);
if (!got) { problems.push(`${id}: never settled, wanted ${want.outcome}`); continue; }
if (got.outcome !== want.outcome) problems.push(`${id}: told ${got.outcome}, wanted ${want.outcome}`);
if (want.code !== undefined && got.code !== want.code) problems.push(`${id}: code ${got.code}, wanted ${want.code}`);
if (want.reason !== undefined && got.reason !== want.reason) problems.push(`${id}: reason ${got.reason}, wanted ${want.reason}`);
const recorded = tracker.state(id)?.recorded;
if (recorded !== want.recorded) problems.push(`${id}: recorded ${recorded}, wanted ${want.recorded}`);
}
for (const id of told.keys()) if (!(id in scenario.expect)) problems.push(`${id}: settled but the scenario expects nothing for it`);
return problems;
});
}
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])]));
return value;
}
let failed = 0;
for (const { name, problems } of results) {
if (problems.length === 0) { console.log(`PASS ${name}`); continue; }
failed++;
console.log(`FAIL ${name}`);
for (const problem of problems) console.log(` ${problem}`);
}
console.log(`${results.length - failed}/${results.length} passed`);
process.exit(failed === 0 ? 0 : 1);