Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
7665df33ae Merge pull request 'TS: write_admitted / error.v1 decoder, WriteTracker, vector replay runner' (#62) from libby/ts-write-receipt-client into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 19:52:37 +00:00
92b4cb7ce9 Merge pull request 'C#: database server answers writes with write_admitted / error.v1 (write-receipt spec)' (#61) from libby/cs-write-receipt-server into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:51:52 +00:00
36494dafb9 Merge pull request 'cultmesh-rs: RUDP document server answers writes with write_admitted / error.v1' (#60) from libby/rs-write-receipt-server into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:49:50 +00:00
5bf2428295 Merge pull request 'spec: cultnet write receipt (write_admitted, error id echo, admitted/refused/unconfirmed)' (#59) from libby/cultnet-write-receipt-spec into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 19:49:40 +00:00
Libby
1a228349f1 C#: a put or delete with an empty or blank schemaId or recordKey is refused message_invalid (as an unreadable frame, like the Rust server) and not applied
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:46:45 +00:00
Libby
621b86ca15 Spec #59: an empty or blank schemaId or recordKey is message_invalid (details.field message, the write's id), never applied; vector for it
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 5m55s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:45:19 +00:00
Libby
90d252735f cultmesh-rs: a refused reliable-sequenced write is received and acknowledged first, and a refused write counts as session activity
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 6m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 52s
Refusing before session.receive left the packet unacknowledged: the client resent it forever and every later ordered write stalled. The test now uses the same peer and proves a later ordered write is admitted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:38:43 +00:00
Libby
dc1ae51e92 cultmesh-ts: publishRudpDocumentOnce builds the put before connecting, so a put that cannot be built keeps its cause on both paths
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 6m13s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:37:08 +00:00
Libby
0fbf2baea6 cultnet-ts, cultmesh-ts: Heimdall's five Lows on send-only (spec wording, error.v1 ack-time refusal tests, build errors keep their cause, presence changelog, ack-cut anchors)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:32:49 +00:00
Libby
89ff3d2cbd cultnet-ts test: the Odin presence publisher is send-only, so a refusal after the transport ack is not awaited
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:26:25 +00:00
Libby
206dbe142b cultmesh-ts: sendOnly tests use one fake server per publish (the reused one threw on the second connect and hung the run); spec names send-only and the presence publisher
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
The hang was the fake server, not the writer: it is bound to one connection id, and two publishes in one test sent a second connect it rejected from inside the socket handler. The unacknowledged-send test also scripted an admit, whose reply piggybacks the put's ack.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:24:52 +00:00
Libby
0caef4d564 WIP (unverified): publishRudpDocumentOnce sendOnly + presence publisher send-only; tests written, cultmesh-ts receipt-client run hung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:24:52 +00:00
Libby
c89c4ee39a cultmesh-ts: test kills the flush-cap mutant
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 5m58s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:07:54 +00:00
Libby
af1ac72359 cultmesh-ts, cultnet-ts: only write_admitted admits a put (no echo admission); the send is bounded by the result window and a timeout is local_send_failed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:05:42 +00:00
Libby
293b4e83bc cultmesh-ts, cultnet-ts: an unanswered put is unconfirmed on every server, session_ended before a result is an error, a failed local send is its own reason
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
a67faad27c cultnet-ts: tests for two tracker mutants (send expires other windows; non-answer frames settle nothing)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
6eea66c57f cultnet-ts, cultmesh-ts: write receipt client (frames, WriteTracker, replay runner, opt-in writer)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:39 +00:00
Libby
34bf7aa0ee C#: test kills the schema id detail-value mutant
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 5m59s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:03:36 +00:00
Libby
40b34295e3 C#: receipt writes need a reliable, non-sequenced channel; a refusal without a record key or schema id carries a null detail value
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:01:59 +00:00
Libby
e4b67a9e0b cultmesh-rs: a receipt session accepts writes only on a reliable, non-sequenced channel; a test pins that a reconnect starts a fresh session
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 10m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m8s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:00:48 +00:00
Libby
74adad916a Spec #59: receipt writes need a reliable, non-sequenced channel; error.v1 schema description names limit_reached; #55-#58 paragraph states which clients still window-succeed
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 9m8s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:59:39 +00:00
Libby
48cf3b3323 C#: tests kill the replay-details and unreliable non-write mutants
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:45:29 +00:00
Libby
0a67b4d8aa C#: a remembered refusal keeps its code and field, never the write's strings, and replays in fixed text; a write on an unreliable channel of a receipt session is refused message_invalid (channel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:42:22 +00:00
Libby
1a05df2524 cultmesh-rs: server-wide remembered-write budget is a maintained counter; a session whose refusal set overflowed refuses unseen ids after the budget frees
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:36:10 +00:00
Libby
2c5203dc82 Spec #59: local_send_failed is its own unconfirmed reason
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 5m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:34:52 +00:00
Libby
3443e1dc93 Spec #59: error.v1 code enum names limit_reached; server-wide budget is a conformance column; an overflowed refusal set refuses unseen ids for the session
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:34:19 +00:00
Libby
c8f869e54e cultnet-rs, cultmesh-rs: null or missing document/schemaId refused naming the field; budget tests use literal bytes; unreliable-refusal local failure test
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 5m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:32:03 +00:00
Libby
d7b36416c6 cultmesh-rs: pin limit-refusal memory bound and legacy unreliable drop (mutant-driven tests)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:30:36 +00:00
Libby
ef5cd88111 C#: v0 errors for snapshot, shard log and variant refusals carry fixed text, never an exception's message
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 5m35s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:13:58 +00:00
Libby
d654cb2b99 C#: tests kill the refusal-set bound and abandoned-rehash mutants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:12:31 +00:00
Libby
1447ccd8dd cultnet-rs, cultmesh-rs: write_admitted in spec field order; server-wide remembered-write budget over digest entries; id size limit; limit refusals remembered; unreliable writes refused; local failure is its own goodbye
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 5m14s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:09:28 +00:00
Libby
c4d1efe000 C#: receipt ids capped at 256 bytes and held as digests; limit refusals remembered, no slot freed; required fields refused with fixed text; RUDP server advertises no receipt frames
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:07:23 +00:00
Libby
734087766a Spec #59: id size, digest memory, limit refusals remembered, required fields, reliable channel, local send failure, per-class conformance table
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 6m0s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:04:21 +00:00
Libby
541d734675 C#: a forward is retryable only when provably not sent; unconfirmed otherwise; refusals carry fixed text
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultNetWriteNotSentException names the pre-send boundary (connect, hello, no endpoint,
or a send port that proves nothing was written). Every other forwarding failure keeps
the session claim. ForUnownedSchema and the routing refusal no longer carry exception text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:02:56 +00:00
Libby
b89a883700 spec: C# RUDP schema server is not receipt-capable; advertise only if end to end
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:58:51 +00:00
Libby
2e5df24e96 C#: loopback test for the unreadable-frame answer; Server.Port seam; the answer now reaches the wire
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
An undecodable frame is classed legacy by Decode, so the schema-side guard was unreachable
and a receipt peer was never answered. The guard moves to the legacy decode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:58:17 +00:00
Libby
74b8a4cbf8 C#: drop the redundant Silent state of a write claim (a silent claim is an unanswered one)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:53:05 +00:00
Libby
0182a6d40b cultnet-rs, cultmesh-rs: remembered-write cap refusal is error.v1 limit_reached; pin authorize-once with a counting sink
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 5m19s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 41s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:32:49 +00:00
Libby
ed5f9d30d1 C#: receipt server follows Heimdall's review of #61
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 5m30s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
Look the id up first and authorize only on first sighting; remember refusals within the cap; never re-forward an id whose primary stayed silent; end the connection when an answer cannot be queued; fixed refusal texts; limit_reached cap refusal; commit-point doc on ApplyPut/Delete.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:32:39 +00:00
Libby
eafcdcdf7c cultnet: add limit_reached to error.v1 code enum, with captured vector
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 4m51s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:29:18 +00:00
Libby
5f699c27fa cultmesh-rs: a put reusing a delete's id is refused (mutant-driven test)
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:27:54 +00:00
Libby
d4f440ac7c cultmesh-rs: receipt server looks the id up first, remembers refusals, answers deletes and unqueueable refusals
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 4m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Follows Heimdall's second review of #60: replay by (session,id) before any
authorization; refusals stored with their hash; delete shares the dedupe
table and settles capability; a refusal the queue cannot take ends the
session; a peer's refusal frames and a receipt session's refused deletes are
not violations; the write limit has its own fixed text; the sink trait says
Ok means committed and visible.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:22:50 +00:00
Libby
b0ea18371f C#: database server answers writes with write_admitted / error.v1 (write-receipt spec)
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 5m46s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Opt-in per connection by the first hello (or first write); the server answers a
capable hello with its own. A put or delete commits, then gets write_admitted under
its id; every refusal is error.v1 with the id (an unreadable frame carries the id
when it still holds one). (actor, messageId) dedupe is bound to the content hash and
asked only after the write authority grants; same id other content is message_invalid.
The shard write forwarder says hello, waits for the primary's receipt, and the server
relays only what the primary answered under the original id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:04:37 +00:00
Libby
7a560b1984 spec: dedupe scope is at least the session; a resend after a reconnect is a new write
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 5m52s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 41s
Per Heimdall's review. The dedupe key lives for the life of its session, bounded per
session without forgetting an id the server promised to answer. A longer retention
is optional and never to be relied on. After a reconnect callers resend only writes
that are idempotent by record key and read back before resending any other.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:58:39 +00:00
Libby
8bd31a5d4a cultmesh-rs: receipt server follows Heimdall's review of #60
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 7m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
A refused put, a full dedupe table and a reused id refuse the write, not the
session; only a protocol violation ends one. A frame that does not decode but
holds a messageId is refused with that id. The server answers a receipt-capable
hello with its own, listing both frames. The sink's authorization (new
authorize_raw_document, default admit) runs before the session's remembered
writes are consulted. A second hello does not change the capability: it is set
once, by the first hello or the first write. A receipt that cannot be queued, and
a write the payload budget would drop silently, end the session instead of
leaving the write unanswered. Refusals to a receipt-capable session are queued
reliably.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:56:17 +00:00
Libby
45e95984e5 WIP C# write-receipt server (not built, not tested): messages, content hash, per-peer dedupe state, receipt-aware wait
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:41:14 +00:00
Libby
9f73047c29 cultmesh-rs: RUDP document server answers writes with write_admitted / error.v1 (write-receipt spec)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
cultnet-rs gains cultnet.write_admitted.v0, cultnet.error.v1 (v0 + messageId) and
write_content_hash. The server opts a session in only when its hello lists both frames
(framing only; admission rules are unchanged). On an opted-in session an admitted put is
answered with a receipt carrying its id after the sink accepts it, a refusal is an error.v1
naming the id, a resend of the same content is re-receipted without reaching the sink, and the
same id with other content is refused message_invalid (never the old receipt). Other sessions
are answered exactly as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:38:46 +00:00
Libby
ab7bc771e3 spec: write receipt per Heimdall's review (dedupe by actor+content hash, admitted-late, 5 s window, capability is framing only)
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 6m28s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 2m19s
- messageId unique per authenticated actor and session; dedupe key (actor, id) bound to SHA-256 of
  [operation, schemaId, recordKey, payload]; same id with other content is message_invalid, never the old receipt.
- A late answer after the window is recorded on the write (admitted_late / refused_late); no blind resend.
- A forwarder with no primary answer relays nothing; the client reports unconfirmed.
- RECEIPT_RESULT_WINDOW_MS 5000 (default), LEGACY_RESULT_WINDOW_MS 500 (fallback only), DEDUPE_RETENTION_MS as vector constants.
- The hello capability changes framing only and never authorizes.
- Batches can commit partially; the caller owns retry.
- Vectors: constants, worked content hashes, 7 dedupe cases, late-answer and window scenarios.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:31:49 +00:00
Libby
662ae048d8 spec: cultnet write receipt (write_admitted.v0, error.v1 id echo, three-outcome clients)
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Specification and shared vectors only, no runtime code. A write is answered
exactly once with cultnet.write_admitted.v0 or cultnet.error.v1 carrying its
messageId; the new frames go only to connections whose hello lists them (today's
TS decoder rejects unknown keys and versions, so "old clients ignore it" is
false). Clients settle admitted | refused | unconfirmed; the result window is the
pre-receipt fallback and ends unconfirmed. Wire vectors, schema-invalid cases and
16 replay scenarios in write-receipt-vectors.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:28:46 +00:00
dc5abedffc Merge pull request 'C#: SubmitDocumentAsync and the shard write forwarder complete on the result frame' (#58) from libby/cs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 7m52s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 13:24:39 +00:00
Libby
09e6a528b8 C#: SubmitDocumentAsync and the shard write forwarder complete on the result 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 6m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
CultNetWriteCompletion.SendAndAwaitResultAsync registers a refusal handler before
the send, then waits a result window (default 500 ms; CultMeshClientOptions.
SubmitResultWindow, CultNetSchemaWriteForwarderOptions.ResultWindow). A
cultnet.error.v0 inside it rejects with the new CultNetWriteRefusedException
(Code: write_not_authorized, message_invalid, ...) whatever its order against the
send or transport ack; a send returning no longer completes the write. The
forwarder's refusal now throws on the same path as an unreachable primary, so
CultNetDatabaseServer.TryForwardPutAsync/DeleteAsync no longer report a refused
forward as done. Raw send primitives (C# schema client/session, Python, Kotlin)
carry the doc line: fire-and-forget, an ack or a return is not admission, read the
result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:24:27 +00:00
1b3b625474 Merge pull request 'cultmesh-rs: single and batch publish complete on the result frame, not the transport ack' (#57) from libby/rs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:18:41 +00:00
Libby
675a6494c1 cultmesh-rs: single and batch publish complete on the result frame, not the transport ack
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 6m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m12s
One loop now serves both: every put acknowledged, then a refusal frame (typed
cultnet.error.v0) fails the call in either order against the ack, and a success
frame or the end of the new result_window option (default 500 ms) completes it.
The batch no longer waits on flush_reliable alone, so it cannot hide a refusal.
Raw send primitives (CultNetRudpSocketTransportConnection::send/send_reliable/
flush_reliable, tcp_framed send) carry the doc line: fire-and-forget, an ack or
a return is not admission, read the result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:18:40 +00:00
Libby
3c68d7d19c Merge remote-tracking branch 'origin/main' into libby/rs-client-ack-finding 2026-11-08 13:15:17 +00:00
3162e2ea66 Merge pull request 'cultnet-ts: the Odin presence publisher completes on the result frame, not the ack' (#56) from libby/ts-client-ack-finding-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:15:08 +00:00
Libby
531fce567d cultnet-ts test: cancel the Odin stub's delayed send when the test ends
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 7m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 57s
The stub's 120 ms second packet fired after peer.close() and threw
ERR_SOCKET_DGRAM_NOT_RUNNING as an uncaught exception, failing whichever
publisher test was running when the timer landed (load-dependent). Timers are
now cleared in the test's after hook instead of swallowing the error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:14:49 +00:00
Libby
0b326fa5ee WIP cultnet-ts: Odin presence publisher completes on the result frame, not the ack (passes alone; two publisher tests fail inside the parallel full run, unexplained)
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 5m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:36:23 +00:00
Libby
cdbe5889e0 merge main 2026-11-08 11:35:00 +00:00
ff1c92d2fe Merge pull request 'cultmesh-ts: a put completes on its result frame, not on the transport ack' (#55) from libby/ts-client-ack-finding-cultmesh into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 11:34:56 +00:00
Libby
7082c9488b cultmesh-ts: a put completes on its result frame, not on the transport ack
publishRudpDocumentOnce resolved when peer.flush returned (ack only) and read no frame, so a
refused put resolved. It now rejects with CultMeshRudpWriteRefusedError (the peer's typed
cultnet.error.v0: write_not_authorized, message_invalid) whether the refusal arrives before the ack,
after it, or within resultWindowMs (default 500 ms) after it; a success reply ends the wait early.
publishRudpDocumentAndWaitForReceipt rejects with the same typed error instead of timing out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:34:55 +00:00
Libby
dc7eec7e6e merge main 2026-11-08 11:33:16 +00:00
d4e4a2c085 Merge pull request 'GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack' (#54) from libby/cs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:21 +00:00
8cfc41d665 Merge pull request 'cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived' (#53) from libby/py-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:18 +00:00
6f2092171f Merge pull request 'cultmesh-rs test: a publish whose refusal arrives before the ack is refused' (#52) from libby/rs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:15 +00:00
de1d6b3444 Merge pull request 'cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first' (#51) from libby/ts-client-ack-is-not-admission-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:12 +00:00
822d660627 Merge pull request 'cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused' (#50) from libby/ts-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:09 +00:00
Libby
c1d24146db GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack
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 8m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:29:50 +00:00
Libby
c6da8fc54a cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived
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 7m26s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:28:23 +00:00
Libby
9e734447f6 cultmesh-rs test (FAILS, findings): publish completes on the ack when the refusal follows it; the batch publish completes on the ack whatever the refusal
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 7m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:53 +00:00
Libby
f53930bc13 cultmesh-rs test: a publish whose refusal arrives before the ack is refused
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 5m16s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:48 +00:00
Libby
dfd090dc55 cultmesh-ts test (FAILS, a finding): publishRudpDocumentOnce completes on the ack of a refused put
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 8m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:07 +00:00
Libby
4f90ea8156 cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused, whether the ack comes before or after the refusal
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 8m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:06 +00:00
Libby
7766348c75 cultnet-ts test (FAILS, a finding): the Odin presence publisher completes on the ack when the refusal follows it
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
Libby
08cf3b34b5 cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first
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 5m59s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
7486f0a326 Merge pull request 'cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged' (#48) from libby/rs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:21:28 +00:00
Libby
b2077fa2bf cultmesh-rs: a Pong in the capped grace acknowledges nothing; the grace deadline saturates
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 8m57s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m41s
A Ping in the 2 s grace after the capped frame was answered with a Pong built from the
session's ack state, which includes the capped frame. Every reply while the session is
ending now zeroes its ack fields. The deadline no longer wraps at the end of the clock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:21:08 +00:00
3915a4e5f5 Merge pull request 'GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session' (#49) from libby/cs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:10:54 +00:00
Libby
0b1a468ddd GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session
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 8m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m13s
The schema server answered the 8th undecodable frame and ended the peer's session in the same
call; ending clears the session's pending reliable packets, so a lost datagram was never
resent. The session now stays until the peer has acknowledged every answer (a lost one is
resent by PollResends) or a 2 s grace runs out, serves nothing after the capped frame
meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:10:48 +00:00
Libby
03141bc93b cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
The capped (8th) refusal went out unreliable and unordered with the goodbye at once, so a
lost datagram left the peer without it. It is now queued like any response and resent until
the peer acknowledges it; the session ends then (or after a 2 s grace) and serves nothing
after the capped frame meanwhile. The answer, its resends and the poll that carried the
capped frame acknowledge nothing, as every refusal. A reliable queue that cannot take the
answer ends the session at once with the unreliable refusal, as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:04:22 +00:00
337168812e Merge pull request 'cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session' (#47) from libby/ts-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 10:59:27 +00:00
Libby
9e5edeca52 cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session
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 7m6s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
endCappedSession deleted the session right after the answer was sent, so the resend
timer never saw it and a lost datagram left the peer without the answer. The session
now stays until the peer has acknowledged every reply (a lost one is resent) or a 2 s
grace runs out, serves nothing after the capped frame meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 10:59:23 +00:00
fe73795d89 Merge pull request 'cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals' (#45) from libby/py-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m3s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 57s
2026-11-08 09:54:41 +00:00
Libby
bf81695537 cultmesh-py: answer the capped frame reliably and end the session once the answer is acknowledged
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 5m17s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 52s
The 8th refusal was sent unreliable and unordered with only the first response,
then the goodbye at once, so a lost datagram left the peer without it. All answers
now go the normal reliable path; the session ends when the peer has acknowledged
them (a lost one is resent) or after a 2 s grace, and serves nothing after the
capped frame meanwhile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:54:37 +00:00
b1d6ec098d Merge pull request 'cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes' (#44) from libby/ts-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:52:26 +00:00
Libby
23c7ce3738 cultmesh-ts: count and answer a frame that decodes but is no message the parser takes
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 6m9s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m17s
A map with an unknown schemaVersion was rethrown: unanswered, uncounted toward the
session cap. It is now answered message_invalid, reported, and counted like any other
unreadable frame, so the cap holds for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:52:22 +00:00
51d2d0cf46 Merge pull request 'GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row' (#46) from libby/cs-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:47:22 +00:00
Libby
89bbb277bd GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row
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 7m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m20s
Heimdall's parity ruling. The schema server ended a peer's session on the first
frame it could not decode. It now answers message_invalid (field "message") and
keeps the session; RudpCultNetSchemaServer.MaxConsecutiveRefusedFrames (8)
consecutive undecodable frames end it after the last is answered, and any
decoded frame resets the run. Handler faults still end the session at once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:47:08 +00:00
Libby
b4979cb65b cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. A frame that is no msgpack map used to be dropped
silently; it is now answered message_invalid (field "message") and the session
kept. MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive unreadable frames, or
consecutive deletes the write authority refuses, end the session after the
capped frame is answered. The authority's decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:43:41 +00:00
Libby
621e05d552 cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. The refusal is still answered and the session kept
below the cap; the frame that reaches CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES
is answered too, then the session ends. The write-authority decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:41:18 +00:00
4828c41646 Merge pull request 'cultmesh-rs: keep the session through an unreadable frame or refused delete; end at 8 in a row' (#43) from libby/keep-session-invalid-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 09:36:55 +00:00
Libby
a0d9ed0cc5 cultmesh-rs: keep the session through an unreadable frame or refused delete; end it at 8 in a row
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 3m28s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 56s
Heimdall's parity ruling: the refusal is answered on the live session, and
MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive undecodable frames (reset by
any decoded frame) or refused deletes (reset by any decoded non-delete) end
it. The sink gate is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:36:41 +00:00
fb705cbc1d Merge pull request 'cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors' (#42) from libby/rs-rudp-typed-refusals into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:33:41 +00:00
Libby
34be64789e cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors
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 5m40s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
An undecodable schema frame was dropped and a DocumentDelete ignored, both
silently. Now the first is refused message_invalid (details.field "message")
and the second write_not_authorized naming the record, each with a fixed
reason, ending the peer's session as the other refusals do. The sink gate is
unchanged. Both bytes are pinned against the shared error-vectors.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:33:27 +00:00
a95b43d48f Merge pull request 'cultnet error contract: one message_invalid code replaces malformed_document_put/delete' (#41) from libby/error-message-invalid into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 4m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-08 09:17:52 +00:00
Libby
a558f3ad3b cultmesh-py: pin details.field on the message_invalid refusals of a malformed or unstorable put
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 4m43s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00
Libby
8f71bff69c cultnet error contract: one message_invalid code replaces malformed_document_put/delete
Q6-A of docs/cultnet-error-contract-cut.md. message_invalid joins the closed
cultnet.error.v0 code enum (both schema copies), with details.field naming the
request field at fault ("message" when the whole frame cannot be read).
C# ForMessageInvalid, Rust CultNetErrorCode::MessageInvalid, TS
cultNetMessageInvalidError and Python message_invalid_error all write the same
captured bytes (error-vectors.json plus the C# and Rust cases). cultmesh-py and
cultmesh-ts refuse malformed puts and deletes with it; the C# subscription
servers use it for the missing subscriptionId.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00
fd119ea36c Merge pull request 'cultmesh-ts: write authority decide timeout counts as a typed refusal' (#40) from libby/ts-decide-timeout into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:13:11 +00:00
Libby
386f036849 cultmesh-ts: test that a grant arriving after the decide timeout is refused and never reaches the handler
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 4m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:13:08 +00:00
Libby
43abf9762f cultmesh-ts: refuse an unusable writeAuthorityTimeoutMs at construction
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 6m2s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Review finding: NaN, Infinity or >2^31-1 becomes a 1 ms Node timer and would
refuse every write that is not instant; 0 and negatives were silently clamped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:59:25 +00:00
Libby
3f150799f0 cultmesh-ts: write authority decide timeout counts as a typed refusal
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 5m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
A never-settling decide() held up the later frames of its session. The
server now races it against writeAuthorityTimeoutMs (default 5000) and
refuses with write_not_authorized on expiry. The granted-path test now
asserts the canonical schemaId the authority saw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:50:07 +00:00
e8b2e44496 Merge pull request 'semver-policy: exact commands for reading the workflow at the tagged sha; evidence for the cultcache-ts-v0.15.0 run' (#39) from eureka/tag-sha-workflow-step into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m42s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-08 03:52:59 +00:00
3dd9bde017 semver-policy: exact commands for the before-pushing-a-tag step, and the 0.15.0 run's evidence
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 6m19s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:51:27 +00:00
cbae9ec6fe Merge pull request 'docs: drop stale cultcache-ts tag-trigger claim from the content map' (#38) from libby/ts-tag-trigger-docs into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 03:49:17 +00:00
Libby
6822da6187 semver-policy, content-map: follow review of the cultcache-ts tag-trigger text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:49:16 +00:00
Libby
5155c5b834 content-map: no cultcache-ts tag triggers publish-packages on main
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 5m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
The C5 plan still said the cultcache-ts tag triggers the npm publish; F16 and the workflow say it does not. npm view 404 re-checked 2026-11-08.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:48:36 +00:00
250d616f8f Merge pull request 'cultmesh-ts: write authority for the RUDP document server' (#37) from libby/cultmesh-ts-write-authority into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m14s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 26s
2026-11-07 20:21:27 +00:00
Libby
3f4139549e cultmesh-ts: CHANGELOG states the refusal size bound
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:21:26 +00:00
Libby
ada30be84e cultmesh-ts: gate before shard refusal, bound the refusal, harden tests
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 5m2s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
A put with wrong-typed shard fields is judged with null shard fields first, then
refused as malformed once granted. The refusal's record key and reason are
clipped so it fits maxPayloadBytes. The allocation test watches transient
allocations; catch-path and delete tests assert ids, operation, reply count,
onError and handlers. README/CHANGELOG no longer claim the schema is resolved
after the gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:13:49 +00:00
Libby
1689b62597 cultmesh-ts: gate unreadable writes, malformed refusals, payload tests
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 5m22s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m5s
A frame that fails to decode or parse is judged as the write it looks like, so
an ungranted peer gets write_not_authorized instead of silence; a granted peer
gets onError and a malformed_document_put/delete refusal. Adds raw-frame tests
(int, 2**70, oversized claimed length, non-decoding, non-map document) and
the timing residual note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:57:42 +00:00
Libby
1bc05d7b06 cultmesh-ts: document server asks a write authority before applying a peer put or delete
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Deny by default with a grant-all opt-in; the gate runs before the put is sized,
resolved or decoded; refusal is the typed write_not_authorized. Adds
onDocumentDeleteRaw, refuses wrong-typed shard fields before the gate, grants
all writes in the existing server tests, and ports the cultmesh-py
remote-write-authority tests (no forwarding).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:52:42 +00:00
Libby
474e27fcb3 WIP B 2026-11-07 19:36:40 +00:00
f0731c6b91 Merge pull request 'cultnet-ts: typed write_not_authorized factory and canonical schema id' (#36) from libby/cultnet-ts-write-refusal into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 36s
2026-11-07 19:36:36 +00:00
Libby
2016ad7457 cultnet-ts: write_not_authorized blank-reason check is C# IsNullOrWhiteSpace
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 5m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Review: trim() differs from string.IsNullOrWhiteSpace on U+0085 and U+FEFF; use the
package's isNullOrWhiteSpaceCSharp and pin both in the test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:36:32 +00:00
Libby
d0c0156874 cultnet-ts: typed write_not_authorized factory and canonical schema id
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) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
cultNetWriteNotAuthorizedError(recordKey, reason?) writes the C# ForRemoteWriteRefused
bytes (pinned to error-vectors.json). CultNetDocumentRegistry.canonicalSchemaId resolves
the id a put or delete writes under, from the id alone, for a server's write authority.
No behaviour change for existing callers; the registry already had a delete apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:35:21 +00:00
10c1f4dd00 Merge pull request 'cultmesh-rs: a sink's refusal of a put is a typed write_not_authorized' (#35) from libby/cultmesh-rs-typed-refusal into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 19:31:48 +00:00
Libby
74ccfaf67a cultmesh-rs: a sink's refusal of a put is a typed write_not_authorized
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 5m48s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 55s
The RUDP document server sent a host sink's refusal as an untyped Error (code None).
It now sends write_not_authorized with details naming the record, as C#, TS and
Python do; the text stays the fixed variant text, never the sink's. The rejection
carries the put's record_key. Other rejections stay untyped. Unit test pins the
bytes to the error-vectors.json write_not_authorized vector; integration tests
check code/details on the wire and that a refused put reaches no sink.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:30:54 +00:00
608ddb94f7 Merge pull request 'cultmesh-py: bind the TCP+UDP pair for port 0 without the race' (#34) from libby/cultmesh-py-port-race into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 19:28:51 +00:00
Libby
cff68d5528 cultmesh-py: bind test proves only the UDP number was taken
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:28:50 +00:00
Libby
e0f5e13a1b cultmesh-py: bind the TCP+UDP pair for port 0 without the race
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 4m46s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
CultMeshLocalServer.start bound TCP to port 0 and then bound UDP to whatever number
the kernel picked, failing with EADDRINUSE whenever another process held that
number for UDP (this host has hundreds of UDP listeners), and left the accept
thread running on failure. Port 0 now retries with a fresh pair (bounded); both
sockets bind before any thread starts; an explicit port still fails honestly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:25:38 +00:00
e2dc04e724 Merge pull request 'cultmesh-py: peer writes need a write authority (default-deny)' (#33) from libby/cultmesh-py-remote-write-authority into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 19:19:27 +00:00
Libby
87c4ac366d cultmesh-py: refuse wrong-typed shard fields before the write gate
A non-string shardId or non-int shardEpoch (bool is not int) on a put or delete
is a malformed_document_put/delete before the authority is asked, so a
shard-scoped authority cannot be bypassed by a shard the store would file under
str(shardId). The authority receives the fields as sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:19:26 +00:00
Libby
f074c3d45f cultmesh-py: document why schema resolution precedes the write gate
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 5m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 30s
Comment at the gate and a README residual; no behaviour change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:17:30 +00:00
Libby
61a1d872b3 cultmesh-py: gate runs before an ungranted peer's payload is touched
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 QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
raw_put_target resolves only a put of the wire shape (string key, bytes payload),
so an int payload is never converted (bytes(10**9)) before the authority speaks.
Shard fields of the wrong type reach the authority as None. Tests: huge payload
refused without allocating, RUDP peer path (endpoint and transport), shard types.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:14:12 +00:00
Libby
2947e15088 cultmesh-py: peer writes need a write authority (default-deny)
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 5m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 20s
Finishes the remote-write gate: exports, daemon --allow-peer-writes, the gate is
total (an ungranted peer learns nothing from malformed/unowned-put replies),
existing server tests grant explicitly, 12 tests port RemoteWriteAuthorityTests
(forwarding has no Python counterpart), refusal bytes pinned to error-vectors.json.
README documents the breaking change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:07:32 +00:00
Libby
7a721204ae WIP cultmesh-py: remote write authority gate (not finished, not for merge)
Adds write_authority.py, node.raw_put_target/raw_delete_target, the gate in
_handle_raw_put/_handle_raw_delete with the peer passed in, and serve_node/serve params.
Missing: __init__ exports and imports in facade.py/session.py, daemon --allow-peer-writes,
tests, README and Breaking note. Untested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:59:47 +00:00
d00222761c Merge pull request 'Docs: cultcache-ts publishing is off; tag runs use the tagged workflow' (#32) from libby/publish-docs-cleanup into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 36s
2026-11-07 16:53:04 +00:00
Libby
bf5a09bd04 publish-packages: say that cultcache-ts publishing is off; keep the tag pin readable
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 6m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 25s
The header comment, semver-policy and F16 of the content map still listed cultcache-ts-v*
as a trigger. They now say publishing is deliberately off and that a tag run uses the
workflow at the tagged commit (cultcache-ts-v0.15.0 sits on 7d292085, before the guard).
semver-policy gains a "Before pushing a tag" note. The guard's comment moves above `tags:`:
inside the list it broke the three `workflow:` tests of check-changelog-semver.test.mjs,
which read `tags:` followed directly by its items.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:53:03 +00:00
e75285c067 Merge pull request 'publish-packages: cultcache-ts-v* tags no longer trigger publish' (#31) from libby/disable-cultcache-ts-tag-publish into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:38:12 +00:00
Libby
8569825630 publish-packages: stop cultcache-ts-v* tags from triggering the npm publish
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 6m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Publishing @gamecult/cultcache-ts is deliberately disabled until ruled on, and whether
NPM_TOKEN is set is unknown. The other tag patterns and workflow_dispatch are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:38:11 +00:00
7d29208539 Merge pull request 'cultcache-ts 0.15.0: fold 0.14.1, 0.14.2 and Unreleased; raise consumer ranges' (#30) from libby/cultcache-ts-0.15.0 into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
Publish packages / cultcache-py to PyPI (push) Has been skipped
Publish packages / cultmesh-py to PyPI (push) Has been skipped
Publish packages / cultnet-py to PyPI (push) Has been skipped
Publish packages / @gamecult/cultcache-ts to npm (push) Failing after 9m25s
2026-11-07 16:34:31 +00:00
Libby
7ac9e60bdc cultcache-ts 0.15.0: fold the untagged 0.14.1/0.14.2 and Unreleased into one entry
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 5m57s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
0.14.1 and 0.14.2 were never tagged; cultcache-ts-v0.14.0 is the last release. The
Unreleased exports (SchemaConflictError, schemaIdentityOf, the document payload codec)
are in no tagged release, which is what failed the declared-ranges check. Package and
lockfile go to 0.15.0 and every consumer range (cultnet-ts, cultmesh-ts, cultmesh-browser)
moves to ^0.15.0, since a 0.x caret does not admit a new minor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:31:04 +00:00
da515b0f5e Merge pull request 'Fix build:ts: one text-only error factory in cultnet-ts (6 TS2345 in cultmesh-ts)' (#29) from libby/cultmesh-ts-error-message-sites into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 16:27:31 +00:00
Libby
d0fcd3e36e cultnet-ts, cultmesh-ts: one factory for the text-only error message
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 5m15s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
PR #20 made routingHint, code and details required on CultNetErrorMessage; six call sites in
cultmesh-ts still built the message without them, so build:ts failed with TS2345. cultnet-ts
now owns cultNetTextError(error), the legacy-union decode uses it too, and the call sites
and two test expectations call it. The types are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:27:24 +00:00
8477d8dc74 Merge pull request 'npm: scope cultnet-ts, cultmesh-ts, cultmesh-browser under @gamecult' (#28) from libby/scope-npm-packages into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 16:25:15 +00:00
Libby
286a9af389 npm: scope cultnet-ts, cultmesh-ts and cultmesh-browser under @gamecult
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 5m49s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Unscoped npm names are unclaimed and open to dependency confusion; cultcache-ts was
already @gamecult/cultcache-ts. package.json names, intra-repo dependencies, package-lock,
import specifiers (src, tests, samples, docs/start, first-call docs test), the workspace
list in run-typescript-workspaces, the verify-eve module map and package READMEs now use
the scoped names. Directory names, workspace paths in CI and release tag prefixes are
unchanged. Historical cut docs and past CHANGELOG entries keep the old names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:25:10 +00:00
655cb2c909 Merge pull request 'cultcache-rs: pin the one-global rule on load_soa (PR #15 gap)' (#27) from libby/cultcache-rs-load-soa-global-test into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:18:28 +00:00
Libby
08715ac55c cultcache-rs: pin the one-global rule on load_soa
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 4m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
load_soa installs a whole table and checks it with refuse_second_globals, but no test
reached that call (PR #15 covered put paths and pull_all). A table holding two records
of a global type is refused with CultCacheSecondGlobal and the prior view survives; a
table with one global loads and replaces the view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:17:05 +00:00
ec7a009b02 Merge pull request 'cultnet: add unsupported_schema_version to the closed cultnet.error.v0 code set' (#26) from libby/error-code-unsupported-schema-version into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:16:17 +00:00
135 changed files with 11044 additions and 566 deletions

View file

@ -8,15 +8,26 @@ name: Publish packages
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin # directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
# consume the core stack. # consume the core stack.
# #
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or # Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package # version must match the version in the package manifest;
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather # scripts/release-after-suite.mjs refuses the release otherwise rather than
# than shipping a mislabelled artifact. # shipping a mislabelled artifact.
#
# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*`
# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from
# workflow_dispatch only, which publishes nothing). A tag run uses the workflow
# file at the tagged commit, not the one on main, so a tag on an older commit
# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that
# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag.
on: on:
push: push:
# cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled
# until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach
# `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing.
# (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs
# reads the list as `tags:` followed directly by its items.)
tags: tags:
- "cultcache-ts-v*"
- "cultcache-py-v*" - "cultcache-py-v*"
- "cultnet-py-v*" - "cultnet-py-v*"
- "cultmesh-py-v*" - "cultmesh-py-v*"

View file

@ -114,7 +114,10 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`, `selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns), `unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and `write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value). `unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) and
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
## Live Invariants ## Live Invariants

View file

@ -28,7 +28,7 @@
"code": { "code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).", "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"], "type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"] "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
}, },
"details": { "details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,7 +36,7 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["field", "value", "asOf", "current"], "required": ["field", "value", "asOf", "current"],
"properties": { "properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }

View file

@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json",
"title": "CultNet Error Message v1",
"description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).",
"type": "object",
"required": [
"schemaVersion",
"error",
"routingHint",
"code",
"details",
"messageId"
],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"const": "cultnet.error.v1"
},
"error": {
"type": "string",
"minLength": 1
},
"routingHint": {
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"shard",
"reason"
],
"properties": {
"shard": {
"type": [
"object",
"null"
]
},
"reason": {
"type": "string"
}
}
},
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.",
"type": [
"string",
"null"
],
"enum": [
null,
"selection_invalid",
"cursor_stale",
"cursor_invalid",
"reference_outside_target",
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
"limit_reached"
]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"field",
"value",
"asOf",
"current"
],
"properties": {
"field": {
"description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).",
"type": [
"string",
"null"
]
},
"value": {
"description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.",
"type": [
"string",
"null"
]
},
"asOf": {
"description": "cursor_stale: the cursor's own minted asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
},
"current": {
"description": "cursor_stale: the answering server's current asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
}
}
},
"messageId": {
"description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.",
"type": [
"string",
"null"
],
"minLength": 1
}
}
}

View file

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json",
"title": "CultNet Write Admitted",
"description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.",
"type": "object",
"required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.write_admitted.v0" },
"messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 },
"operation": { "enum": ["put", "delete"] },
"schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 },
"recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 }
}
}

View file

@ -145,6 +145,22 @@
"current": null "current": null
} }
} }
},
{
"name": "message_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "message_invalid: Raw put messages must contain a document map.",
"routingHint": null,
"code": "message_invalid",
"details": {
"field": "document",
"value": null,
"asOf": null,
"current": null
}
}
} }
] ]
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
# CultNet write receipt (specification)
Status: specification. No runtime implements it yet; the conformance follow-ups below do.
Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission).
Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them.
## The problem
A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer).
## Frames
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
## Capability: opting in
A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end.
**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in.
## Server rules
1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers.
2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission.
3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read.
4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer.
5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.**
- Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice.
- Same key, same content hash, answer was a refusal: the same refusal again.
- **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged.
- **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically.
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A `schemaId` or `recordKey` that is empty or only whitespace (present but names nothing) is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"` and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store.
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once.
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
## Client rules: three outcomes
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.
**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.
**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.
**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing.
**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids.
## 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: 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
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
## Conformance status
| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) |
|---|---|---|
| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies |
| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies |
| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) |
| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a |
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
## Conformance follow-ups (not in this PR)
C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog.

View file

@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo.
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source | | F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep | | F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source | | F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` | | F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returned 404 anonymously on 2026-11-08 for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 | | F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` | | F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim | | F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
@ -276,11 +276,10 @@ remains:
history (Keep a Changelog headings). history (Keep a Changelog headings).
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`, - A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`. `cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
These go in **two pushes** (at most three tags per push, SKILL.md): These go in **two pushes** (at most three tags per push, SKILL.md). No
`cultcache-ts` first, because its tag triggers `publish-packages.yml` `-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's publish nothing;
npm publish job still runs, and to where, is checked before that push; follow-up `npm-publish-ts-packages` owns npm publishing.
follow-up `npm-publish-ts-packages` owns the rest.
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the - The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
tagged commit) is StreamPixels' B-cuts, not this campaign. tagged commit) is StreamPixels' B-cuts, not this campaign.
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass. - Size: about 60 lines of version and CHANGELOG text; a short Hands pass.

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: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: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-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 | | `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: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`) | | `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/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) | | `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 | | C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |

View file

@ -26,7 +26,7 @@ caught before it ships instead of written up afterward.
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag | | `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") | | `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag | | `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` | | `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml`, `workflow_dispatch` only; npm publishing is off (see "Coverage gaps") |
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
@ -198,8 +198,8 @@ or a missing declaration refuses), and:
proof: with no tag of its own prefix it is a first release. The suite test proof: with no tag of its own prefix it is a first release. The suite test
that pins every declared prefix guards it: each prefix is declared once, each that pins every declared prefix guards it: each prefix is declared once, each
workflow tag trigger must be a declared prefix, and each such package's prefix workflow tag trigger must be a declared prefix, and each such package's prefix
must be a workflow trigger or, for the Caching Unity package the workflow does must be a workflow trigger or, for a package the workflow does not tag-trigger
not build, the prefix of tags that hold its own manifest at the tag's own (the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own
version, so a typo or a copied prefix fails the suite. Nothing releases version, so a typo or a copied prefix fails the suite. Nothing releases
without the suite passing; see below for how each release path runs it. without the suite passing; see below for how each release path runs it.
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
@ -352,10 +352,40 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
at unchanged source and CRLF checkout means this SDK or host differs from the at unchanged source and CRLF checkout means this SDK or host differs from the
one that built the tracked DLL. one that built the tracked DLL.
### Before pushing a tag
A tag run uses the workflow files at the tagged commit, not the ones on main, and
the forge's GitHub mirror forwards the tag, so both CI systems run it. Before
pushing any tag, resolve the commit it will point at and read the tag-triggering
workflows as they are at that commit:
```
sha=$(git rev-parse <commit-the-tag-will-point-at>)
git ls-tree -r --name-only "$sha" .github/workflows
git show "$sha":.github/workflows/publish-packages.yml | sed -n '/^on:/,/^jobs:/p'
```
Confirm that the tag's prefix is a trigger there if you mean it to publish, and
is not if you do not. Do the same for every other workflow listed that has a
`tags:` trigger. A guard merged after that commit does not protect a tag that
points before it.
`cultcache-ts-v0.15.0` was pushed on `7d292085`, which predates the guard, and
its push started a publish run on both systems (GitHub run 42392334869, forge run
454). The forge job log shows how it ended: the Publish step ran
`release-after-suite.mjs`, whose `npm test` failed (11 cross-runtime tests,
`cargo: not found` on the runner), so it printed "@gamecult/cultcache-ts's own
tests failed; not releasing." and never reached `npm publish`. Neither
`cultcache-ts` nor `@gamecult/cultcache-ts` exists on the npm registry
(both return 404, checked 2026-11-08). The GitHub run's own log needs a login
to read, so its failure cause is inferred from the identical workflow and sha.
### Coverage gaps ### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate `cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm
publishing is off until ruled on, and the cultcache-ts job runs from
`workflow_dispatch` only, publishing nothing) — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`, (`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags `src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags

View file

@ -284,7 +284,7 @@ public static class CultNetFirstCall
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports `npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`. from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`.
```ts ```ts
import { createServer, connect } from "node:net"; import { createServer, connect } from "node:net";
@ -294,7 +294,7 @@ import {
CultNetDocumentRegistry, CultNetDocumentRegistry,
createTcpFramedCultNetPeer, createTcpFramedCultNetPeer,
defineCultNetDocumentBinding, defineCultNetDocumentBinding,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
const noteDocument = defineDocumentType({ const noteDocument = defineDocumentType({
type: "note", type: "note",

View file

@ -9,7 +9,7 @@ leave because both lease the same document.
| Piece | Status | | Piece | Status |
| --- | --- | | --- | --- |
| Two clients, real WebSockets, real `cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) | | Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
| Edge clamp, and the source below matching the files that ran | **tested** | | Edge clamp, and the source below matching the files that ran | **tested** |
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) | | Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe | | The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
@ -19,7 +19,7 @@ leave because both lease the same document.
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority | | Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported | | `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
No `cultmesh-ts` or `cultnet-ts` TypeScript authority server exists. The server below is the CultNet No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet
message handling a provider owes, written in this recipe with the `ws` package. message handling a provider owes, written in this recipe with the `ws` package.
## Run it ## Run it
@ -61,8 +61,8 @@ export const arenaSize = 100;
import { createServer, type RequestListener, type Server } from "node:http"; import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts"; import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws"; import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
@ -163,7 +163,7 @@ export async function startArena(port = 0, host = "127.0.0.1", onRequest?: Reque
## client.ts ## client.ts
```ts ```ts
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. // Runs unchanged in a browser or in Node 22+: both have a global WebSocket.

View file

@ -1,4 +1,4 @@
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. // Runs unchanged in a browser or in Node 22+: both have a global WebSocket.

View file

@ -1,8 +1,8 @@
import { createServer, type RequestListener, type Server } from "node:http"; import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts"; import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws"; import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";

41
package-lock.json generated
View file

@ -482,6 +482,18 @@
"resolved": "packages/cultcache-ts", "resolved": "packages/cultcache-ts",
"link": true "link": true
}, },
"node_modules/@gamecult/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/@gamecult/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/@gamecult/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/@jsdevtools/ono": { "node_modules/@jsdevtools/ono": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
@ -865,18 +877,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -1184,7 +1184,7 @@
}, },
"packages/cultcache-ts": { "packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.14.2", "version": "0.15.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
@ -1200,15 +1200,16 @@
} }
}, },
"packages/cultmesh-browser": { "packages/cultmesh-browser": {
"name": "@gamecult/cultmesh-browser",
"version": "0.4.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@gamecult/cultmesh-ts": "^0.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"cultmesh-ts": "^0.2.0",
"typescript": "^5.9.0" "typescript": "^5.9.0"
}, },
"engines": { "engines": {
@ -1216,11 +1217,12 @@
} }
}, },
"packages/cultmesh-ts": { "packages/cultmesh-ts": {
"name": "@gamecult/cultmesh-ts",
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.3.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -1233,10 +1235,11 @@
} }
}, },
"packages/cultnet-ts": { "packages/cultnet-ts": {
"name": "@gamecult/cultnet-ts",
"version": "0.3.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.1", "@gamecult/cultcache-ts": "^0.15.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1" "ajv": "^8.17.1"
}, },

View file

@ -2,7 +2,8 @@
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces. //! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
use cultcache_rs::{ use cultcache_rs::{
CacheBackingStore, CultCache, CultCacheSecondGlobal, DatabaseEntry, SingleFileMessagePackBackingStore, CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry,
SingleFileMessagePackBackingStore,
}; };
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
@ -321,3 +322,31 @@ fn loading_a_store_that_holds_two_globals_is_refused() {
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>()); assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing"); assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
} }
// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two
// records of a global type is refused whole and the prior view survives.
#[test]
fn loading_an_soa_table_that_holds_two_globals_is_refused() {
let source = cache();
let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap();
let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap();
let mut loading = cache();
loading.put("g0", &settings("prior")).unwrap();
loading.put("plain:1", &Plain { value: "prior".into() }).unwrap();
let refusal = loading
.load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second]))
.unwrap_err();
let second_global = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()];
named.sort();
assert_eq!(named, ["g1", "g2"], "the refusal names both records");
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("prior")), "a refused load keeps the prior view");
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), Some(Plain { value: "prior".into() }));
// One global in the table is the rule's own case, not a second.
loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap();
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("one")));
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), None, "a load replaces the view");
}

View file

@ -3,29 +3,26 @@
All notable changes to `@gamecult/cultcache-ts` are recorded here, following All notable changes to `@gamecult/cultcache-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## [0.15.0]
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`. - A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store. - A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
### Added ### Added
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did. - `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader. - `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity. - `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
- The document payload codec, exported from `@gamecult/cultcache-ts/document`: `decodeDocumentPayload`, `encodeDocumentPayload`, and the two named byte escapes `documentPayloadFromBytes` and `documentPayloadBytes`.
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`). - `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store. - `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store.
- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses. - A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses.
## [0.14.2]
### Added
- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`, - `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`,
interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows). interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows).
`push` and `delete` read the current file, merge and replace it under the lock, so concurrent `push` and `delete` read the current file, merge and replace it under the lock, so concurrent
@ -35,27 +32,18 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
is, but C# writers can still overwrite records a concurrent TypeScript writer added. is, but C# writers can still overwrite records a concurrent TypeScript writer added.
- The staged file is fsynced before the replace, and on Unix the directory after it. A - The staged file is fsynced before the replace, and on Unix the directory after it. A
directory sync failure after the replace is reported as replaced but not durable. directory sync failure after the replace is reported as replaced but not durable.
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
(`cultnet-ts/content`) bound payload nesting with the same check the cache
applies on decode.
### Changed ### Changed
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers - The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock. apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken - Opening a legacy store that needs healing rewrites it under the lock, from a read taken
under the lock. under the lock.
### Fixed
- A full `pushAll` reads the store's header under the lock first and refuses a store format
this runtime does not read (the same error a read gives), leaving the file as it was. A
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
that are not UTF-8 is refused by a whole write as a read refuses it.
### Dependencies ### Dependencies
- `koffi` 3.3.0, loaded only on the first write. - `koffi` 3.3.0, loaded only on the first write.
## [0.14.1]
### Added
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
(`cultnet-ts/content`) bound payload nesting with the same check the cache
applies on decode.

View file

@ -1,6 +1,6 @@
{ {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.14.2", "version": "0.15.0",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -1,8 +1,14 @@
# Changelog # Changelog
All notable changes to `cultmesh-browser` are recorded here, following All notable changes to `@gamecult/cultmesh-browser` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased
### Breaking
- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`.
## [0.4.0] ## [0.4.0]
### Added ### Added

View file

@ -1,6 +1,6 @@
# CultMesh Browser # CultMesh Browser
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one `@gamecult/cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
document leases across route replacement, and correlates typed CultNet document leases across route replacement, and correlates typed CultNet
operations with provider receipts. operations with provider receipts.
@ -95,7 +95,7 @@ WebCrypto `crypto.subtle`; without WebCrypto it fails with
endpoint CultMesh resolved is the only host the reader trusts. endpoint CultMesh resolved is the only host the reader trusts.
```ts ```ts
import { fetchVerifiedContent } from "cultmesh-browser"; import { fetchVerifiedContent } from "@gamecult/cultmesh-browser";
const bytes = await fetchVerifiedContent(endpoint, manifest, { const bytes = await fetchVerifiedContent(endpoint, manifest, {
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),

View file

@ -1,5 +1,5 @@
{ {
"name": "cultmesh-browser", "name": "@gamecult/cultmesh-browser",
"version": "0.4.0", "version": "0.4.0",
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
"type": "module", "type": "module",
@ -41,12 +41,12 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"cultmesh-ts": "^0.2.0", "@gamecult/cultmesh-ts": "^0.2.0",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
} }

View file

@ -3,8 +3,8 @@
// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence). // The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence).
// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here. // Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here.
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
import { isLoopbackEndpoint } from "cultnet-ts/authority"; import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority";
export type CultMeshContentErrorCode = export type CultMeshContentErrorCode =
| "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network" | "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network"

View file

@ -1,6 +1,6 @@
import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document"; import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document";
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts"; import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
encodeCultNetMessageForWire, encodeCultNetMessageForWire,
parseCultNetMessage, parseCultNetMessage,
@ -16,7 +16,7 @@ import {
type CultMeshVerseCatalogResponseMessage, type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionAcceptedMessage, type CultMeshSessionAcceptedMessage,
type CultMeshSessionOpenMessage, type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts"; } from "@gamecult/cultnet-ts/contracts";
import { import {
base64ToBytes, base64ToBytes,
bytesToBase64, bytesToBase64,
@ -31,7 +31,7 @@ import {
type CultMeshAuthorityTrustMode, type CultMeshAuthorityTrustMode,
type CultMeshAuthorityTrustPolicy, type CultMeshAuthorityTrustPolicy,
type CultMeshP256PublicKey, type CultMeshP256PublicKey,
} from "cultnet-ts/authority"; } from "@gamecult/cultnet-ts/authority";
export * from "./content.js"; export * from "./content.js";
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;

View file

@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { build } from "esbuild"; import { build } from "esbuild";
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js"; import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js";

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
encodeCultNetMessageForWire, encodeCultNetMessageForWire,
parseCultNetMessage, parseCultNetMessage,
@ -12,7 +12,7 @@ import {
type CultNetRawDocumentRecord, type CultNetRawDocumentRecord,
type CultMeshVerseCatalogResponseMessage, type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionOpenMessage, type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts"; } from "@gamecult/cultnet-ts/contracts";
import { import {
CultMeshBrowserClient, CultMeshBrowserClient,

View file

@ -5,8 +5,8 @@ import { readFileSync } from "node:fs";
import { createServer } from "node:https"; import { createServer } from "node:https";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { chunkRecordKey } from "cultnet-ts/content"; import { chunkRecordKey } from "@gamecult/cultnet-ts/content";
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content"; import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content";
const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string]; const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string];
const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); };

View file

@ -3,7 +3,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { decodeManifest } from "cultnet-ts/content"; import { decodeManifest } from "@gamecult/cultnet-ts/content";
import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js"; import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js";
const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined]; const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined];

View file

@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content"; import { EMPTY_SHA256, encodeManifest, packContent } from "@gamecult/cultnet-ts/content";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop

View file

@ -2,8 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
import { decodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts"; import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts";
import { decodeCultNetPayload } from "../src/index.js"; import { decodeCultNetPayload } from "../src/index.js";

View file

@ -808,6 +808,7 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
val profile: CultNetTransportProfile val profile: CultNetTransportProfile
val stats: CultNetTransportStats val stats: CultNetTransportStats
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchemaMessage(message: CultNetMessage) fun sendSchemaMessage(message: CultNetMessage)
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage? fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
@ -3167,13 +3168,16 @@ class CultNetRudpSocketTransportConnection(
return connected return connected
} }
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: ByteArray) { fun send(channelId: String, payload: ByteArray) {
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) } session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
framesSent += 1 framesSent += 1
} }
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8)) fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchema(payload: ByteArray) = send("schema", payload) fun sendSchema(payload: ByteArray) = send("schema", payload)
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8)) fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))

View file

@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the
local TCP/RUDP server, and a package daemon. local TCP/RUDP server, and a package daemon.
It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and
`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream `@gamecult/cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
negotiation, and simulation fact contracts are defined in negotiation, and simulation fact contracts are defined in
`cultnet_py.cultmesh_contracts` and re-exported here. `cultnet_py.cultmesh_contracts` and re-exported here.
@ -122,6 +122,33 @@ server = CultMesh.serve_node(
) )
``` ```
## Peer writes
**Breaking (0.x):** a served node refuses every peer `document_put_raw` and `document_delete` unless a write
authority grants it, mirroring C# `ICultNetRemoteWriteAuthority`. Before this, any connected peer could overwrite
or delete the node's documents. The refusal is a typed `cultnet.error.v0` with code `write_not_authorized` and the
record key in `details`; an authority that raises, or returns no decision, refuses too.
Known residual: ungranted peers can still distinguish registered from unregistered schemas by timing (the node resolves the
schema before the gate so the authority sees the canonical id); the refusal itself is identical.
```python
from cultmesh_py import CultMeshGrantAllWrites, CultMeshRemoteWriteDecision, CultMeshRemoteWriteRequest
class OwnKeysOnly:
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
if request.record_key.startswith("input:python:"):
return CultMeshRemoteWriteDecision.grant()
return CultMeshRemoteWriteDecision.refuse("not your key")
server = CultMesh.serve_node(node, write_authority=OwnKeysOnly())
```
The request carries the peer (`endpoint`, `transport`), the operation, the canonical schema id the node
resolved (never the alias the peer sent), the record key and the shard id and epoch as sent. `session.serve(...)`
takes the same `write_authority`. A host that already authenticates its peers, or a test rig, opts in to
`CultMeshGrantAllWrites()`; the daemon's equivalent is `--allow-peer-writes`. Without it the daemon is read-only to peers.
The same local server can be launched as a package daemon when another runtime The same local server can be launched as a package daemon when another runtime
or operator process needs a CultNet endpoint without embedding Python glue: or operator process needs a CultNet endpoint without embedding Python glue:

View file

@ -39,6 +39,14 @@ from .simulation import (
CultMeshSimulationFactCommitter, CultMeshSimulationFactCommitter,
simulation_fact_document, simulation_fact_document,
) )
from .write_authority import (
CultMeshGrantAllWrites,
CultMeshRemoteWriteAuthority,
CultMeshRemoteWriteDecision,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
)
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_REQUEST,
PEER_EXCHANGE_RESPONSE, PEER_EXCHANGE_RESPONSE,
@ -89,6 +97,12 @@ __all__ = [
"CultMeshSimulationFact", "CultMeshSimulationFact",
"CultMeshSimulationFactCommit", "CultMeshSimulationFactCommit",
"CultMeshSimulationFactCommitter", "CultMeshSimulationFactCommitter",
"CultMeshGrantAllWrites",
"CultMeshRemoteWriteAuthority",
"CultMeshRemoteWriteDecision",
"CultMeshRemoteWriteOperation",
"CultMeshRemoteWriteRequest",
"CultMeshServerPeer",
"CultMeshAuthorityLease", "CultMeshAuthorityLease",
"CultMeshAuthorityLeaseCatalog", "CultMeshAuthorityLeaseCatalog",
"CultMeshHmacAuthorityLeaseVerifier", "CultMeshHmacAuthorityLeaseVerifier",

View file

@ -13,6 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser
from .facade import CultMesh from .facade import CultMesh
from .server import CultMeshLocalServer from .server import CultMeshLocalServer
from .write_authority import CultMeshGrantAllWrites
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
CultMeshPeerCard, CultMeshPeerCard,
CultMeshPeerCatalog, CultMeshPeerCatalog,
@ -53,6 +54,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5) parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5)
parser.add_argument("--verse-id") parser.add_argument("--verse-id")
parser.add_argument("--verse-display-name") parser.add_argument("--verse-display-name")
parser.add_argument("--allow-peer-writes", action="store_true", help="grant every connected peer put and delete; without it peer writes are refused")
parser.add_argument("--role", action="append", dest="roles") parser.add_argument("--role", action="append", dest="roles")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@ -117,6 +119,7 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C
display_name=args.display_name, display_name=args.display_name,
max_snapshot_documents=args.max_snapshot_documents, max_snapshot_documents=args.max_snapshot_documents,
max_snapshot_bytes=args.max_snapshot_bytes, max_snapshot_bytes=args.max_snapshot_bytes,
write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None,
) )
except ValueError as error: except ValueError as error:
# The server owns its option rules; a limit it refuses is a usage error, not a crash. # The server owns its option rules; a limit it refuses is a usage error, not a crash.

View file

@ -35,6 +35,7 @@ from .node import (
from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer
from .session import CultMeshGameSession, CultMeshGameSessionOptions from .session import CultMeshGameSession, CultMeshGameSessionOptions
from .simulation import CultMeshSimulationFactCommitter from .simulation import CultMeshSimulationFactCommitter
from .write_authority import CultMeshRemoteWriteAuthority
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
AuthorityLeaseVerifier, AuthorityLeaseVerifier,
CultMeshAuthorityLeaseCatalog, CultMeshAuthorityLeaseCatalog,
@ -400,6 +401,7 @@ class CultMesh:
verse_catalog: CultMeshVerseCatalog | None = None, verse_catalog: CultMeshVerseCatalog | None = None,
peer_catalog: CultMeshPeerCatalog | None = None, peer_catalog: CultMeshPeerCatalog | None = None,
observation_hub: CultNetSimulationObservationHub | None = None, observation_hub: CultNetSimulationObservationHub | None = None,
write_authority: CultMeshRemoteWriteAuthority | None = None,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 0, port: int = 0,
display_name: str | None = None, display_name: str | None = None,
@ -416,6 +418,7 @@ class CultMesh:
verse_catalog=verse_catalog or CultMeshVerseCatalog(), verse_catalog=verse_catalog or CultMeshVerseCatalog(),
peer_catalog=peer_catalog or CultMeshPeerCatalog(), peer_catalog=peer_catalog or CultMeshPeerCatalog(),
observation_hub=observation_hub, observation_hub=observation_hub,
write_authority=write_authority,
host=host, host=host,
port=port, port=port,
display_name=display_name, display_name=display_name,

View file

@ -650,6 +650,29 @@ class CultMeshDatabase:
self._append_shard_log_delete(message) self._append_shard_log_delete(message)
return message return message
def raw_put_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
"""The canonical schema id and record key a raw put would write, or None when it would not apply."""
document_record = message.get("document")
# Only a document of the wire shape is resolved, so an ungranted peer's payload is never
# converted or allocated here: a malformed one is the store's refusal, after the authority.
if (
not isinstance(document_record, dict)
or not isinstance(document_record.get("recordKey"), str)
or not isinstance(document_record.get("payload"), (bytes, bytearray, memoryview))
):
return None
try:
prepared = self._raw_put_envelope(message)
except Exception: # noqa: BLE001 - any other malformed shape
return None
return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key)
def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
"""The canonical schema id and record key a raw delete names, or None when its schema is not registered."""
document = self._document_for_schema(str(message.get("schemaId") or ""))
record_key = str(message.get("recordKey") or "")
return None if document is None or not record_key else (document.catalog_entry().schema_id, record_key)
def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None: def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None:
prepared = self._raw_put_envelope(message) prepared = self._raw_put_envelope(message)
if prepared is None: if prepared is None:

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import errno
import logging import logging
import socket import socket
import threading import threading
@ -10,6 +11,8 @@ from typing import Any
import msgpack import msgpack
from cultnet_py import ( from cultnet_py import (
error_message,
message_invalid_error,
unsupported_schema_version_error, unsupported_schema_version_error,
CultNetRawSnapshotResponse, CultNetRawSnapshotResponse,
CultNetSimulationObservationHub, CultNetSimulationObservationHub,
@ -28,6 +31,12 @@ from cultnet_py import (
) )
from .node import CultMeshNode from .node import CultMeshNode
from .write_authority import (
CultMeshRemoteWriteAuthority,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
)
# The shortest message id this server answers a snapshot request under. It takes a # The shortest message id this server answers a snapshot request under. It takes a
# request's messageId as given, an empty one included, so no response it sends is smaller. # request's messageId as given, an empty one included, so no response it sends is smaller.
@ -51,6 +60,17 @@ from cultnet_py.cultmesh_contracts import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# How many consecutive refused frames of one kind a RUDP session may send before it is ended: undecodable
# frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any decoded
# frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
# frame that reaches this number is answered too, then the session ends. The same number is the cap in every
# runtime.
MAX_CONSECUTIVE_REFUSED_FRAMES = 8
# How long a session whose capped frame was answered waits for the peer to acknowledge the answer before it ends anyway.
_RUDP_CAPPED_ANSWER_GRACE_MS = 2000
_EPHEMERAL_PAIR_ATTEMPTS = 64
class _ReplyUnqueueable(Exception): class _ReplyUnqueueable(Exception):
"""A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is """A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is
@ -80,6 +100,10 @@ class _DatabaseSubscription:
class _RudpPeerConnection: class _RudpPeerConnection:
session: CultNetRudpSession session: CultNetRudpSession
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict) subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
undecodable_frames_in_a_row: int = 0
refused_deletes_in_a_row: int = 0
# Set once the peer's capped frame has been answered: the session ends when the answers are acknowledged.
ending_deadline_ms: int | None = None
@dataclass @dataclass
@ -88,6 +112,9 @@ class CultMeshLocalServer:
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog) verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog) peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
observation_hub: CultNetSimulationObservationHub | None = None observation_hub: CultNetSimulationObservationHub | None = None
# Asked once per peer put and delete. None refuses every one of them (write_not_authorized); a node's own
# writes never come through here.
write_authority: CultMeshRemoteWriteAuthority | None = None
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 0 port: int = 0
display_name: str | None = None display_name: str | None = None
@ -158,26 +185,49 @@ class CultMeshLocalServer:
def start(self) -> "CultMeshLocalServer": def start(self) -> "CultMeshLocalServer":
if self._socket is not None: if self._socket is not None:
return self return self
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server, rudp_socket = self._bind_sockets()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((self.host, self.port))
server.listen()
server.settimeout(0.2) server.settimeout(0.2)
self._socket = server self._socket = server
self.port = int(server.getsockname()[1]) self.port = int(server.getsockname()[1])
self._stop.clear() self._stop.clear()
self._thread = threading.Thread(target=self._accept_loop, daemon=True) self._thread = threading.Thread(target=self._accept_loop, daemon=True)
self._thread.start() self._thread.start()
if self.enable_rudp: if rudp_socket is not None:
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
rudp_socket.bind((self.host, self.port))
rudp_socket.settimeout(0.02) rudp_socket.settimeout(0.02)
self._rudp_socket = rudp_socket self._rudp_socket = rudp_socket
self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True) self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True)
self._rudp_thread.start() self._rudp_thread.start()
return self return self
def _bind_sockets(self) -> tuple[socket.socket, socket.socket | None]:
"""The listening TCP socket and, when RUDP is on, the UDP socket on the same port number.
An explicit port is bound as asked and a failure is the caller's. Port 0 asks for any free port, and the
kernel picks the TCP one without knowing whether another process holds that number for UDP, so a
UDP collision retries with a fresh TCP port instead of failing the start. Both sockets are bound
before any thread starts, so a failed start leaves nothing running or open.
"""
attempts = _EPHEMERAL_PAIR_ATTEMPTS if self.port == 0 and self.enable_rudp else 1
for attempt in range(attempts):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
rudp_socket = None
try:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((self.host, self.port))
server.listen()
if self.enable_rudp:
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
rudp_socket.bind((self.host, int(server.getsockname()[1])))
return server, rudp_socket
except OSError as error:
server.close()
if rudp_socket is not None:
rudp_socket.close()
if error.errno != errno.EADDRINUSE or attempt == attempts - 1:
raise
raise AssertionError("unreachable")
def stop(self) -> None: def stop(self) -> None:
self._stop.set() self._stop.set()
if self._rudp_socket is not None: if self._rudp_socket is not None:
@ -238,14 +288,15 @@ class CultMeshLocalServer:
if server is None: if server is None:
return return
try: try:
client, _ = server.accept() client, address = server.accept()
except TimeoutError: except TimeoutError:
continue continue
except OSError: except OSError:
return return
threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start() peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp")
threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start()
def _handle_connection(self, client: socket.socket) -> None: def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None:
with client: with client:
stream = client.makefile("rwb") stream = client.makefile("rwb")
transport = TcpFramedTransportConnection( transport = TcpFramedTransportConnection(
@ -267,7 +318,7 @@ class CultMeshLocalServer:
if not isinstance(message, dict): if not isinstance(message, dict):
continue continue
try: try:
responses = self._handle_connection_message(message, subscriptions) responses = self._handle_connection_message(message, subscriptions, peer=peer)
except Exception as error: # noqa: BLE001 - one message never ends the connection except Exception as error: # noqa: BLE001 - one message never ends the connection
# Whatever a peer's message makes the handling raise is that peer's failure: # Whatever a peer's message makes the handling raise is that peer's failure:
# it is refused with fixed text and the connection serves on. The log names # it is refused with fixed text and the connection serves on. The log names
@ -309,6 +360,7 @@ class CultMeshLocalServer:
if packet.connection_id != self.rudp_connection_id: if packet.connection_id != self.rudp_connection_id:
continue continue
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms() now_ms = _now_ms()
peer = peers.get(remote_addr) peer = peers.get(remote_addr)
if packet.packet_type == CultNetRudpPacketType.CONNECT: if packet.packet_type == CultNetRudpPacketType.CONNECT:
@ -357,13 +409,31 @@ class CultMeshLocalServer:
for delivered in result.delivered: for delivered in result.delivered:
if delivered.channel_id != "schema": if delivered.channel_id != "schema":
continue continue
if peer.ending_deadline_ms is not None:
# The capped frame was answered; nothing after it is served while the answer is delivered.
break
try: try:
message = msgpack.unpackb(delivered.payload, raw=False) message = msgpack.unpackb(delivered.payload, raw=False)
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError): except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
continue message = None
if not isinstance(message, dict): if isinstance(message, dict):
continue peer.undecodable_frames_in_a_row = 0
responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True) responses = self._handle_connection_message(
message,
peer.subscriptions,
over_rudp=True,
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
)
refused_delete = message.get("schemaVersion") == "cultnet.document_delete.v0" and any(
response.get("code") == "write_not_authorized" for response in responses
)
peer.refused_deletes_in_a_row = peer.refused_deletes_in_a_row + 1 if refused_delete else 0
refused, run = refused_delete, peer.refused_deletes_in_a_row
else:
# A frame that is no CultNet message is answered, not dropped, and the session kept.
peer.undecodable_frames_in_a_row += 1
refused, run = True, peer.undecodable_frames_in_a_row
responses = [self._message_invalid({}, "message", "CultMesh RUDP server could not read the message.")]
for response in responses: for response in responses:
if unsendable is None: if unsendable is None:
unsendable = self._send_rudp_schema_frame( unsendable = self._send_rudp_schema_frame(
@ -373,6 +443,11 @@ class CultMeshLocalServer:
msgpack.packb(response, use_bin_type=True), msgpack.packb(response, use_bin_type=True),
message_id=str(response.get("messageId") or ""), message_id=str(response.get("messageId") or ""),
) )
if refused and run >= MAX_CONSECUTIVE_REFUSED_FRAMES:
# The capped frame is answered like any other, reliably. The session ends once the peer
# has acknowledged every answer (or the grace runs out): see _end_answered_peers.
peer.ending_deadline_ms = now_ms + _RUDP_CAPPED_ANSWER_GRACE_MS
break
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
# Whatever a peer's message makes the handling raise is that peer's failure. # Whatever a peer's message makes the handling raise is that peer's failure.
# The session recorded the frame's sequence, so it cannot be kept: a retransmit # The session recorded the frame's sequence, so it cannot be kept: a retransmit
@ -403,11 +478,26 @@ class CultMeshLocalServer:
if unsendable is not None: if unsendable is not None:
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable) self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
def _end_answered_peers(
self,
rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None:
"""Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
answer is resent first), or when the grace has run out."""
now_ms = _now_ms()
for remote_addr, peer in list(peers.items()):
if peer.ending_deadline_ms is None:
continue
if peer.session.outstanding_reliable_packet_count == 0 or now_ms >= peer.ending_deadline_ms:
self._end_refused_peer(rudp_socket, peers, remote_addr)
def _poll_rudp_resends( def _poll_rudp_resends(
self, self,
rudp_socket: socket.socket, rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection], peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None: ) -> None:
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms() now_ms = _now_ms()
for remote_addr, peer in list(peers.items()): for remote_addr, peer in list(peers.items()):
for packet in peer.session.due_resends(now_ms): for packet in peer.session.due_resends(now_ms):
@ -519,6 +609,7 @@ class CultMeshLocalServer:
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*, *,
over_rudp: bool = False, over_rudp: bool = False,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
schema_version = message.get("schemaVersion") schema_version = message.get("schemaVersion")
if schema_version == "cultnet.database_subscribe.v0": if schema_version == "cultnet.database_subscribe.v0":
@ -542,9 +633,9 @@ class CultMeshLocalServer:
subscriptions.pop(str(message.get("subscriptionId") or ""), None) subscriptions.pop(str(message.get("subscriptionId") or ""), None)
return [] return []
if schema_version == "cultnet.document_put_raw.v0": if schema_version == "cultnet.document_put_raw.v0":
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp) return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp, peer=peer)
if schema_version == "cultnet.document_delete.v0": if schema_version == "cultnet.document_delete.v0":
return self._handle_raw_delete(message, subscriptions) return self._handle_raw_delete(message, subscriptions, peer=peer)
if schema_version == "cultnet.simulation_observation.v0": if schema_version == "cultnet.simulation_observation.v0":
return self._candidate_responses(message) return self._candidate_responses(message)
response = self.handle_message(message) response = self.handle_message(message)
@ -556,14 +647,27 @@ class CultMeshLocalServer:
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*, *,
over_rudp: bool = False, over_rudp: bool = False,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
document_record = message.get("document") document_record = message.get("document")
if not isinstance(document_record, dict): if not isinstance(document_record, dict):
return [self._error_response( return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")]
"Raw put messages must contain a document map.", malformed_shard = self._malformed_shard_fields(message)
message_id=str(message.get("messageId") or ""), if malformed_shard is not None:
code="malformed_document_put", return [malformed_shard]
)] # Refusal precedes every other judgment of the put. The node resolves the schema first, because the
# authority must be asked about the canonical schema id (a payload-inferred alias included), as C#
# does; only a payload of the wire shape is resolved, so nothing of an ungranted peer's is
# allocated. A put the node cannot resolve is asked about under the ids as sent. Residual: the
# refusal is the same either way, but an ungranted peer can still tell registered from unregistered
# schemas by timing.
target = self.node.database.raw_put_target(message) or (
str(document_record.get("schemaId") or ""),
str(document_record.get("recordKey") or ""),
)
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.PUT, target, message)
if refusal is not None:
return [refusal]
unservable = self._unservable_put(message, over_rudp=over_rudp) unservable = self._unservable_put(message, over_rudp=over_rudp)
if unservable is not None: if unservable is not None:
return [unservable] return [unservable]
@ -575,11 +679,7 @@ class CultMeshLocalServer:
# raises. It is refused to the peer that sent it; no thread and no other peer is # raises. It is refused to the peer that sent it; no thread and no other peer is
# affected. Neither the refusal nor the log quotes the error, which can quote the put. # affected. Neither the refusal nor the log quotes the error, which can quote the put.
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__) _LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
return [self._error_response( return [self._message_invalid(message, "document", "Raw put document could not be stored.")]
"Raw put document could not be stored.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)]
if change is None: if change is None:
return [self._error_response( return [self._error_response(
"Raw put message did not apply to a registered document.", "Raw put message did not apply to a registered document.",
@ -598,15 +698,22 @@ class CultMeshLocalServer:
self, self,
message: dict[str, Any], message: dict[str, Any],
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
schema_id = str(message.get("schemaId") or "") schema_id = str(message.get("schemaId") or "")
record_key = str(message.get("recordKey") or "") record_key = str(message.get("recordKey") or "")
if not schema_id or not record_key: if not schema_id or not record_key:
return [self._error_response( return [self._message_invalid(
"Raw delete messages must contain schemaId and recordKey.", message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_delete",
)] )]
malformed_shard = self._malformed_shard_fields(message)
if malformed_shard is not None:
return [malformed_shard]
target = self.node.database.raw_delete_target(message) or (schema_id, record_key)
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.DELETE, target, message)
if refusal is not None:
return [refusal]
change = self.node.database.apply_raw_delete_message(message) change = self.node.database.apply_raw_delete_message(message)
if change is None: if change is None:
return [self._error_response( return [self._error_response(
@ -616,6 +723,70 @@ class CultMeshLocalServer:
)] )]
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id) return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
def _message_invalid(self, message: dict[str, Any], field: str, reason: str) -> dict[str, Any]:
"""The typed `message_invalid` refusal naming the request field at fault, under the request's messageId."""
response = message_invalid_error(field, reason).to_wire()
response["messageId"] = str(message.get("messageId") or "")
return response
def _malformed_shard_fields(self, message: dict[str, Any]) -> dict[str, Any] | None:
"""The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is
not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can
read, and the store never files a write under a shard the authority did not see."""
shard_id = message.get("shardId")
shard_epoch = message.get("shardEpoch")
if (shard_id is None or isinstance(shard_id, str)) and (
shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool))
):
return None
return self._message_invalid(
message,
"shardId" if not (shard_id is None or isinstance(shard_id, str)) else "shardEpoch",
"shardId must be a string and shardEpoch an integer.",
)
def _refuse_unauthorized_write(
self,
peer: CultMeshServerPeer | None,
operation: CultMeshRemoteWriteOperation,
target: tuple[str, str],
message: dict[str, Any],
) -> dict[str, Any] | None:
"""None when the write may proceed, else the typed refusal for the sending peer.
No authority configured refuses; an authority that raises refuses too (fail closed) and is logged.
"""
schema_id, record_key = target
reason: str | None
authority = self.write_authority
if authority is None:
reason = "no remote write authority is configured"
else:
request = CultMeshRemoteWriteRequest(
peer=peer or CultMeshServerPeer("", "unknown"),
operation=operation,
schema_id=schema_id,
record_key=record_key,
shard_id=message.get("shardId"),
shard_epoch=message.get("shardEpoch"),
)
try:
decision = authority.decide(request)
except Exception as error: # noqa: BLE001 - a failing authority refuses
_LOGGER.error("CultMesh remote write authority failed (%s); the write was refused.", type(error).__name__)
decision = None
reason = "the write authority failed"
else:
reason = getattr(decision, "reason", None)
if decision is not None and getattr(decision, "is_granted", False) is True:
return None
_LOGGER.warning("CultMesh %s of %s refused: write not authorized.", operation.value, schema_id)
return error_message(
f"write_not_authorized: {reason}" if reason and reason.strip() else "write_not_authorized",
code="write_not_authorized",
details={"field": "recordKey", "value": record_key},
).to_wire()
def _database_change_notifications( def _database_change_notifications(
self, self,
message: dict[str, Any], message: dict[str, Any],
@ -803,11 +974,7 @@ class CultMeshLocalServer:
and isinstance(document_record.get("recordKey"), str) and isinstance(document_record.get("recordKey"), str)
and isinstance(document_record.get("payload"), (bytes, bytearray)) and isinstance(document_record.get("payload"), (bytes, bytearray))
): ):
return self._error_response( return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
"Raw put documents must carry a string recordKey and a binary payload.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)
if self.max_snapshot_bytes is None and not over_rudp: if self.max_snapshot_bytes is None and not over_rudp:
return None return None
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID) alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)

View file

@ -27,6 +27,7 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes
if TYPE_CHECKING: if TYPE_CHECKING:
from .server import CultMeshLocalServer from .server import CultMeshLocalServer
from .write_authority import CultMeshRemoteWriteAuthority
@dataclass(frozen=True) @dataclass(frozen=True)
@ -83,6 +84,7 @@ class CultMeshGameSession:
def serve( def serve(
self, self,
*, *,
write_authority: CultMeshRemoteWriteAuthority | None = None,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 0, port: int = 0,
display_name: str | None = None, display_name: str | None = None,
@ -96,6 +98,7 @@ class CultMeshGameSession:
verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(), verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(),
peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(), peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(),
observation_hub=self.observation_hub if self._serve_simulation_observations else None, observation_hub=self.observation_hub if self._serve_simulation_observations else None,
write_authority=write_authority,
host=host, host=host,
port=port, port=port,
display_name=display_name, display_name=display_name,

View file

@ -0,0 +1,66 @@
"""The one question a CultMesh server asks before it applies a peer's write.
Mirrors C# ``ICultNetRemoteWriteAuthority``: a server with no authority refuses every peer put and
delete, so serving a node never lets a connected peer overwrite its documents by default.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class CultMeshRemoteWriteOperation(Enum):
PUT = "put"
DELETE = "delete"
@dataclass(frozen=True)
class CultMeshServerPeer:
"""The connection a write arrived on: its endpoint and transport, nothing the peer claimed."""
endpoint: str
transport: str
@dataclass(frozen=True)
class CultMeshRemoteWriteRequest:
"""One peer write as the authority sees it.
``schema_id`` is the canonical id the node resolved for the write, never an alias the peer sent.
``shard_id`` and ``shard_epoch`` are as the peer sent them.
"""
peer: CultMeshServerPeer
operation: CultMeshRemoteWriteOperation
schema_id: str
record_key: str
shard_id: str | None = None
shard_epoch: int | None = None
@dataclass(frozen=True)
class CultMeshRemoteWriteDecision:
is_granted: bool
reason: str | None = None
@staticmethod
def grant() -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision(True)
@staticmethod
def refuse(reason: str | None = None) -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision(False, reason)
class CultMeshRemoteWriteAuthority(Protocol):
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: ...
class CultMeshGrantAllWrites:
"""Grants every peer write. For a host that has already authenticated its peers (or a test rig); it is
an explicit choice because serving without an authority refuses writes."""
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision.grant()

View file

@ -0,0 +1,52 @@
"""Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes only on
its result frame (write_not_authorized, or the success reply).
The Python write path is a subscription over RUDP: `send` queues the put, `read_next` returns the result frame
and raises the peer's refusal. The server here refuses the put; its reliable ack of the put arrives (the test
waits for it) and the client still reports the refusal, never success. The transport's `receive` yields data
frames only, so the order of the ack against the refusal cannot change what `read_next` reports.
"""
from __future__ import annotations
import unittest
from cultcache_py import define_database_entry_type
from cultmesh_py import CultMesh, create_node
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetDatabaseSubscription, CultNetPeerError, create_rudp_schema_transport
from test_remote_write_authority import KEY_A, NOTE, SCHEMA_ID, put_of
class ClientWriteCompletionTests(unittest.TestCase):
def test_a_put_the_server_refused_is_reported_refused_after_its_ack_arrived(self) -> None:
node = create_node(runtime_id="completion-server")
node.database.register_document(NOTE)
server = CultMeshLocalServer(node=node, port=0, write_authority=None)
server.start()
try:
subscription = CultNetDatabaseSubscription(
host="127.0.0.1",
port=server.port,
timeout_seconds=4.0,
subscription_id="completion",
schema_ids=[SCHEMA_ID],
include_snapshot=False,
create_transport=lambda: create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
),
)
with subscription as sub:
sub.send(put_of(KEY_A, "peer"))
sub._transport.flush_reliable(2.0) # the put's reliable ack has arrived
with self.assertRaises(CultNetPeerError) as raised:
sub.read_next()
finally:
server.stop()
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
self.assertIsNone(server.node.cache.get(NOTE, KEY_A))
if __name__ == "__main__":
unittest.main()

View file

@ -24,7 +24,9 @@ from cultcache_py import (
define_document_type, define_document_type,
) )
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
from cultmesh_py.server import MAX_CONSECUTIVE_REFUSED_FRAMES
from cultmesh_py.verify import verify from cultmesh_py.verify import verify
from cultmesh_py.write_authority import CultMeshRemoteWriteDecision
from cultnet_py import ( from cultnet_py import (
compute_simulation_claim_hash, compute_simulation_claim_hash,
CultNetClientAuthorityScope, CultNetClientAuthorityScope,
@ -68,7 +70,7 @@ from cultnet_py import (
snapshot_request, snapshot_request,
) )
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
from cultmesh_py import create_node from cultmesh_py import CultMeshGrantAllWrites, create_node
from cultmesh_py import ( from cultmesh_py import (
CultMesh, CultMesh,
CultMeshDatabase, CultMeshDatabase,
@ -1631,7 +1633,7 @@ class CultMeshTests(unittest.TestCase):
) )
primary = CultMesh.create_node(runtime_id="forward-primary") primary = CultMesh.create_node(runtime_id="forward-primary")
primary.database.register_document(document) primary.database.register_document(document)
server = CultMesh.serve_node(primary) server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites())
try: try:
shard = CultNetShardDescriptor( shard = CultNetShardDescriptor(
shard_id="forward", shard_id="forward",
@ -3015,7 +3017,7 @@ class CultMeshTests(unittest.TestCase):
source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
target = CultMesh.create_node(runtime_id="subscription-alias-target") target = CultMesh.create_node(runtime_id="subscription-alias-target")
target.database.register_document(document) target.database.register_document(document)
server = CultMesh.serve_node(source) server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
@ -3533,6 +3535,7 @@ class CultMeshTests(unittest.TestCase):
sys.executable, sys.executable,
"-m", "-m",
"cultmesh_py.daemon", "cultmesh_py.daemon",
"--allow-peer-writes",
"--runtime-id", "--runtime-id",
"live-daemon-peer", "live-daemon-peer",
"--port", "--port",
@ -3723,6 +3726,7 @@ class CultMeshTests(unittest.TestCase):
sys.executable, sys.executable,
"-m", "-m",
"cultmesh_py.daemon", "cultmesh_py.daemon",
"--allow-peer-writes",
"--runtime-id", "--runtime-id",
"forwarder-daemon-peer", "forwarder-daemon-peer",
"--port", "--port",
@ -3998,6 +4002,7 @@ class CultMeshTests(unittest.TestCase):
server = CultMesh.serve_node( server = CultMesh.serve_node(
node, node,
verse_catalog=verses, verse_catalog=verses,
write_authority=CultMeshGrantAllWrites(),
peer_catalog=peers, peer_catalog=peers,
observation_hub=observation_hub, observation_hub=observation_hub,
display_name="Mesh Server", display_name="Mesh Server",
@ -4274,7 +4279,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with raw_client.subscribe_database( with raw_client.subscribe_database(
@ -4323,7 +4328,7 @@ class CultMeshTests(unittest.TestCase):
shard_epoch=1, shard_epoch=1,
) )
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with raw_client.subscribe_database( with raw_client.subscribe_database(
@ -4420,7 +4425,7 @@ class CultMeshTests(unittest.TestCase):
def served_alone_bytes(key: str, body_length: int) -> int: def served_alone_bytes(key: str, body_length: int) -> int:
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe") node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, enable_rudp=False) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), enable_rudp=False)
try: try:
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot) response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
finally: finally:
@ -4434,7 +4439,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-served-bound") node = CultMesh.create_node(runtime_id="mesh-served-bound")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=limit, enable_rudp=False)
try: try:
refused, _ = exchange(server, put("put-over", "note:over", 101)) refused, _ = exchange(server, put("put-over", "note:over", 101))
stored_after_refusal = node.database.get(document, "note:over") stored_after_refusal = node.database.get(document, "note:over")
@ -4498,7 +4503,7 @@ class CultMeshTests(unittest.TestCase):
document = self._rudp_bound_note() document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-large") node = CultMesh.create_node(runtime_id="mesh-rudp-large")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
served, payload = self._rudp_exchange( served, payload = self._rudp_exchange(
server, server,
@ -4524,7 +4529,7 @@ class CultMeshTests(unittest.TestCase):
def served_alone_bytes(body_length: int) -> int: def served_alone_bytes(body_length: int) -> int:
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe") node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
_, payload = self._rudp_exchange( _, payload = self._rudp_exchange(
server, self._rudp_note_put(document, "probe", key, body_length), server, self._rudp_note_put(document, "probe", key, body_length),
@ -4554,7 +4559,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-rudp-bound") node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
try: try:
refusals = [ refusals = [
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0] self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
@ -4792,7 +4797,7 @@ class CultMeshTests(unittest.TestCase):
body_for = self._rudp_body_for(document, "note:w") body_for = self._rudp_body_for(document, "note:w")
node = CultMesh.create_node(runtime_id="mesh-rudp-wide") node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=1)
try: try:
transport = create_rudp_schema_transport( transport = create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
@ -4857,7 +4862,7 @@ class CultMeshTests(unittest.TestCase):
document = self._rudp_bound_note() document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed") node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
refusals = [] refusals = []
for field_name, value in ( for field_name, value in (
@ -4880,11 +4885,146 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in refusals: for message_id, refusal in refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id) self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertEqual(after["messageId"], "after") self.assertEqual(after["messageId"], "after")
self.assertEqual(after["documents"], []) self.assertEqual(after["documents"], [])
self.assertTrue(alive) self.assertTrue(alive)
def _rudp_open(self, server: Any) -> Any:
return create_rudp_schema_transport(
host="127.0.0.1",
port=server.port,
connection_id=server.rudp_connection_id,
timeout_seconds=4.0,
max_fragment_bytes=1200,
)
def _rudp_answer(self, transport: Any, payload: bytes) -> dict[str, Any]:
transport.send("schema", payload)
return msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
def _assert_rudp_session_ended(self, transport: Any, server: Any) -> None:
transport.send("schema", msgpack.packb(snapshot_request(message_id="after-cap").to_wire(), use_bin_type=True))
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
self.assertTrue(server._rudp_thread.is_alive())
def test_cultmesh_local_server_answers_unreadable_frames_and_ends_the_session_at_eight_in_a_row(self) -> None:
# An undecodable frame (not a msgpack map) is answered message_invalid and the session kept;
# eight in a row end it (the eighth is answered too); any decoded frame resets the run.
self.assertEqual(MAX_CONSECUTIVE_REFUSED_FRAMES, 8)
node = CultMesh.create_node(runtime_id="mesh-rudp-unreadable")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
garbage = b"\xc1"
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
for _ in range(2):
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
reply = self._rudp_answer(transport, garbage)
self.assertEqual(reply["code"], "message_invalid")
self.assertEqual(reply["details"]["field"], "message")
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, garbage)
transport.send("schema", garbage)
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_resends_a_lost_capped_answer_before_it_ends_the_session(self) -> None:
# The eighth refusal is answered reliably: the datagram carrying it is lost on the wire, and the
# peer still gets the answer (by resend) before the goodbye. An unreliable final answer, or a
# goodbye sent at once, would leave the peer without it.
node = CultMesh.create_node(runtime_id="mesh-rudp-lost-answer")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
send = server._send_rudp_packet
dropped: list[Any] = []
armed = threading.Event()
def lossy(rudp_socket: Any, remote_addr: Any, packet: Any) -> Any:
if armed.is_set() and not dropped and packet.packet_type == CultNetRudpPacketType.DATA and b"message_invalid" in (packet.payload or b""):
dropped.append(packet)
return None
return send(rudp_socket, remote_addr, packet)
server._send_rudp_packet = lossy
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
armed.set()
reply = self._rudp_answer(transport, b"\xc1")
self.assertEqual(len(dropped), 1, "fixture: the first transmission of the last answer was lost")
self.assertEqual(reply["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_serves_nothing_after_the_capped_frame(self) -> None:
node = CultMesh.create_node(runtime_id="mesh-rudp-after-cap")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
transport.send("schema", b"\xc1")
transport.send("schema", msgpack.packb(snapshot_request(message_id="behind-the-cap").to_wire(), use_bin_type=True))
first = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(first["code"], "message_invalid")
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_ends_the_session_at_eight_refused_deletes_in_a_row(self) -> None:
class GrantOnlyKeyB:
def decide(self, request: Any) -> Any:
return CultMeshRemoteWriteDecision.grant() if request.record_key == "note:b" else CultMeshRemoteWriteDecision.refuse("no")
document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-deletes")
node.database.register_document(document)
server = CultMesh.serve_node(node, write_authority=GrantOnlyKeyB())
def delete(key: str) -> bytes:
return msgpack.packb({
"schemaVersion": "cultnet.document_delete.v0",
"messageId": f"del-{key}",
"schemaId": document.schema_id,
"recordKey": key,
}, use_bin_type=True)
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
def refused_run(count: int) -> None:
for _ in range(count):
reply = self._rudp_answer(transport, delete("note:a"))
self.assertEqual(reply["code"], "write_not_authorized")
self.assertEqual(reply["details"]["value"], "note:a")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# A granted delete resets the run (nothing is stored under it, so the store answers that).
self.assertEqual(self._rudp_answer(transport, delete("note:b"))["code"], "unregistered_document_delete")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# So does any decoded non-delete frame.
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
transport.send("schema", delete("note:a"))
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "write_not_authorized")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None: def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a # Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
# payload its schema cannot decode, a global document under another key. Each is refused # payload its schema cannot decode, a global document under another key. Each is refused
@ -4917,7 +5057,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-unstorable-put") node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
node.database.register_document(document) node.database.register_document(document)
node.database.register_document(global_document) node.database.register_document(global_document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()] rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire()) rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
@ -4939,7 +5079,8 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in rudp_refusals + tcp_refusals: for message_id, refusal in rudp_refusals + tcp_refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id) self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertNotIn("note:ok", refusal["error"], message_id) self.assertNotIn("note:ok", refusal["error"], message_id)
self.assertEqual(len(tcp_refusals), 5) self.assertEqual(len(tcp_refusals), 5)
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0") self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
@ -5053,9 +5194,11 @@ class CultMeshTests(unittest.TestCase):
self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0") self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
self.assertIn("unsupported_schema_version", unsupported["error"]) self.assertIn("unsupported_schema_version", unsupported["error"])
self.assertEqual(malformed_put["messageId"], "bad-put") self.assertEqual(malformed_put["messageId"], "bad-put")
self.assertEqual(malformed_put["code"], "malformed_document_put") self.assertEqual(malformed_put["code"], "message_invalid")
self.assertEqual(malformed_put["details"]["field"], "document")
self.assertEqual(malformed_delete["messageId"], "bad-delete") self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete") self.assertEqual(malformed_delete["code"], "message_invalid")
self.assertEqual(malformed_delete["details"]["field"], "recordKey")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version") self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1") self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")

View file

@ -0,0 +1,338 @@
"""A peer's put or delete is applied only when the server's write authority grants it.
Ports the behaviours of C# RemoteWriteAuthorityTests. The C# forwarding behaviour (a refused write is never
forwarded) has no counterpart: this server does not forward non-primary writes. The handlers are reached with
the peer the connection loop would pass, and one test drives the same path over a real TCP connection.
"""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from typing import Any
import msgpack
from cultcache_py import define_database_entry_type
from cultmesh_py import (
CultMesh,
CultMeshGrantAllWrites,
CultMeshRemoteWriteDecision,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
create_node,
)
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message
SCHEMA_ID = "sha256:authority-note"
ALIAS = "runtime.generated.authority-note.ui.42"
SHARD_ID = "authority-shard"
KEY_A = "tests:authority:a"
KEY_B = "tests:authority:b"
PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp")
_REPO = Path(__file__).resolve().parents[3]
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
NOTE = define_database_entry_type(
"tests.authority_note",
[("schema_version", 0), ("text", 1)],
schema_id=SCHEMA_ID,
schema_name="tests.authority_note",
schema_version="tests.authority_note.v1",
)
class Recording:
def __init__(self, decide: Any) -> None:
self._decide = decide
self.requests: list[CultMeshRemoteWriteRequest] = []
def decide(self, request: CultMeshRemoteWriteRequest) -> Any:
self.requests.append(request)
return self._decide(request)
def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
return document_put_raw(
message_id="wire",
key=key,
schema_id=schema_id,
stored_at="2026-11-07T00:00:00Z",
payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}),
shard_id=SHARD_ID,
shard_epoch=1,
).to_wire()
def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
return document_delete(
message_id="wire-delete", schema_id=schema_id, record_key=key, shard_id=SHARD_ID, shard_epoch=1
).to_wire()
class RemoteWriteAuthorityTests(unittest.TestCase):
def build(self, authority: Any = None) -> CultMeshLocalServer:
node = create_node(runtime_id="authority-server")
node.database.register_document(NOTE)
return CultMeshLocalServer(node=node, port=0, write_authority=authority)
def text_of(self, server: CultMeshLocalServer, key: str) -> str | None:
value = server.node.cache.get(NOTE, key)
return None if value is None else value["text"]
def send(self, server: CultMeshLocalServer, message: dict[str, Any]) -> list[dict[str, Any]]:
subscriptions: dict[str, Any] = {}
return server._handle_connection_message(message, subscriptions, peer=PEER)
def assert_refused(self, replies: list[dict[str, Any]], key: str, *, error: str | None = None) -> None:
self.assertEqual(len(replies), 1)
reply = replies[0]
self.assertEqual(reply["schemaVersion"], "cultnet.error.v0")
self.assertEqual(reply["code"], "write_not_authorized")
self.assertTrue(reply["error"].startswith("write_not_authorized"))
self.assertEqual(reply["details"]["field"], "recordKey")
self.assertEqual(reply["details"]["value"], key)
if error is not None:
self.assertEqual(reply["error"], error)
def seed(self, server: CultMeshLocalServer, key: str, text: str) -> None:
# The host writes its own record through its own door, which asks no remote authority.
self.assertEqual(server.node.database.apply_raw_put_message(put_of(key, text)).change_kind, "added")
def test_with_no_authority_a_peers_put_and_delete_are_refused_and_change_nothing(self) -> None:
server = self.build(None)
self.seed(server, KEY_A, "server")
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
self.assertEqual(self.text_of(server, KEY_A), "server")
self.assertIsNone(self.text_of(server, KEY_B))
self.assertEqual(sum("write not authorized" in line for line in logs.output), 3)
def test_a_refusal_publishes_no_change_to_a_subscriber(self) -> None:
server = self.build(None)
server.start()
try:
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
with self.assertRaises(CultNetPeerError) as raised:
sub.send(put_of(KEY_A, "peer"))
sub.read_next()
finally:
server.stop()
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
self.assertIsNone(self.text_of(server, KEY_A))
def test_a_granted_put_and_delete_commit_and_the_authority_saw_this_peer(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
put_replies = self.send(server, put_of(KEY_A, "peer"))
self.assertEqual(self.text_of(server, KEY_A), "peer")
self.assertEqual(put_replies, [])
self.send(server, delete_of(KEY_A))
self.assertIsNone(self.text_of(server, KEY_A))
self.assertEqual(len(authority.requests), 2)
self.assertTrue(all(request.peer is PEER for request in authority.requests))
def test_the_request_carries_the_resolved_write_and_scoped_grants_refuse_their_neighbours(self) -> None:
authority = Recording(
lambda request: CultMeshRemoteWriteDecision.grant()
if request.operation is CultMeshRemoteWriteOperation.PUT and request.record_key == KEY_A
else CultMeshRemoteWriteDecision.refuse("scoped")
)
server = self.build(authority)
self.seed(server, KEY_A, "server")
self.send(server, put_of(KEY_A, "aliased", schema_id=ALIAS))
request = authority.requests[0]
self.assertEqual(request.schema_id, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent")
self.assertIs(request.operation, CultMeshRemoteWriteOperation.PUT)
self.assertEqual(request.record_key, KEY_A)
self.assertEqual(request.shard_id, SHARD_ID)
self.assertEqual(request.shard_epoch, 1)
self.assertEqual(self.text_of(server, KEY_A), "aliased")
self.assert_refused(self.send(server, put_of(KEY_B, "other-key")), KEY_B)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assertIsNone(self.text_of(server, KEY_B), "a grant scoped to key a refuses key b")
self.assertEqual(self.text_of(server, KEY_A), "aliased", "a grant scoped to put refuses delete")
self.assertIs(authority.requests[-1].operation, CultMeshRemoteWriteOperation.DELETE)
self.assertEqual(authority.requests[-1].schema_id, SCHEMA_ID)
def test_refusal_precedes_every_other_judgment_of_the_put(self) -> None:
refusing = self.build(None)
unowned = put_of("unrelated:authority", "x", schema_id="sha256:nobody-owns-this")
unowned["document"]["payload"] = msgpack.packb(["nobody.owns.this.v1", "x"], use_bin_type=True)
self.assert_refused(self.send(refusing, unowned), "unrelated:authority")
blank_key = put_of("", "x")
self.assert_refused(self.send(refusing, blank_key), "")
granting = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.grant()))
reply = self.send(granting, unowned)
self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal")
def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None:
def explode(_: Any) -> Any:
raise RuntimeError("authority exploded")
server = self.build(Recording(explode))
self.seed(server, KEY_A, "server")
with self.assertLogs("cultmesh_py.server", level="ERROR") as logs:
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assertEqual(self.text_of(server, KEY_A), "server", "fail closed")
self.assertTrue(any("RuntimeError" in line for line in logs.output))
def test_a_refusal_with_no_reason_still_says_why(self) -> None:
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse()))
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A, error="write_not_authorized")
def test_the_refusal_names_the_key_and_the_code_in_the_pinned_bytes(self) -> None:
vector = next(v for v in _VECTORS if v["name"] == "write_not_authorized")
message = error_message(
"write_not_authorized: not granted",
code="write_not_authorized",
details={"field": "recordKey", "value": "some:key"},
)
self.assertEqual(message.to_bytes(), bytes.fromhex(vector["messagePackHex"]))
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse("not granted")))
reply = self.send(server, put_of("some:key", "peer"))[0]
self.assertEqual(msgpack.packb(reply, use_bin_type=True), bytes.fromhex(vector["messagePackHex"]))
def test_the_peer_receives_the_typed_refusal_over_tcp_and_a_grant_sends_nothing_back(self) -> None:
authority = Recording(
lambda request: CultMeshRemoteWriteDecision.grant()
if request.record_key == KEY_A
else CultMeshRemoteWriteDecision.refuse("only a")
)
server = self.build(authority)
server.start()
try:
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
sub.send(put_of(KEY_A, "peer"))
self.assertEqual(sub.read_next_change().record_key, KEY_A, "the granted write was answered by its change, not an error")
sub.send(put_of(KEY_B, "peer"))
with self.assertRaises(CultNetPeerError) as raised:
sub.read_next()
finally:
server.stop()
reply = raised.exception.response
self.assertEqual(reply["error"], "write_not_authorized: only a")
self.assertEqual(reply["details"]["value"], KEY_B)
self.assertTrue(authority.requests[0].peer.endpoint.startswith("127.0.0.1:"))
self.assertEqual(authority.requests[0].peer.transport, "tcp")
self.assertEqual(self.text_of(server, KEY_A), "peer")
self.assertIsNone(self.text_of(server, KEY_B))
def test_a_delete_sent_under_a_schema_alias_asks_the_authority_with_the_canonical_id(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.seed(server, KEY_A, "server")
self.send(server, delete_of(KEY_A, schema_id="tests.authority_note.v1"))
request = authority.requests[0]
self.assertIs(request.operation, CultMeshRemoteWriteOperation.DELETE)
self.assertEqual(request.schema_id, SCHEMA_ID)
self.assertIsNone(self.text_of(server, KEY_A), "the aliased delete was granted and applied")
def test_a_missing_decision_refuses_put_and_delete(self) -> None:
server = self.build(Recording(lambda _: None))
self.seed(server, KEY_A, "server")
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
self.assertEqual(self.text_of(server, KEY_A), "server")
self.assertIsNone(self.text_of(server, KEY_B))
def test_a_peer_over_rudp_is_refused_with_its_rudp_endpoint_and_the_server_serves_on(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.refuse("rudp no"))
server = self.build(authority)
server.start()
try:
transport = create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
)
try:
transport.send("schema", msgpack.packb(put_of(KEY_A, "peer"), use_bin_type=True))
reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
transport.send("schema", msgpack.packb(delete_of(KEY_A), use_bin_type=True))
delete_reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
finally:
transport.close()
finally:
server.stop()
self.assertEqual(reply["error"], "write_not_authorized: rudp no")
self.assertEqual(delete_reply["code"], "write_not_authorized")
self.assertEqual([r.peer.transport for r in authority.requests], ["rudp", "rudp"])
self.assertTrue(all(r.peer.endpoint.startswith("127.0.0.1:") for r in authority.requests))
self.assertIsNone(self.text_of(server, KEY_A))
def test_a_put_with_an_absurd_payload_is_refused_by_the_gate_not_crashed_on(self) -> None:
server = self.build(None)
absurd = put_of(KEY_A, "x")
absurd["document"]["payload"] = 2**70
self.assert_refused(self.send(server, absurd), KEY_A)
def test_an_ungranted_peers_huge_payload_is_refused_without_allocating_it(self) -> None:
import tracemalloc
server = self.build(None)
huge = put_of(KEY_A, "x")
huge["document"]["payload"] = 10**9 # bytes(10**9) would allocate a gigabyte
tracemalloc.start()
try:
replies = self.send(server, huge)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
self.assert_refused(replies, KEY_A)
self.assertLess(peak, 10_000_000)
def test_wrong_typed_shard_fields_are_refused_before_the_gate_and_write_nothing(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.seed(server, KEY_B, "server")
shards_before = server.node.database.shard_ids()
for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)):
for operation in ("put", "delete"):
with self.subTest(field=field, value=value, operation=operation):
message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B)
message[field] = value
replies = self.send(server, message)
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", field)])
self.assertEqual(authority.requests, [], "the authority was never asked")
self.assertIsNone(self.text_of(server, KEY_A), "nothing was written")
self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted")
self.assertEqual(server.node.database.shard_ids(), shards_before, "no shard or log entry was filed")
def test_well_typed_shard_fields_reach_the_authority_as_sent(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.send(server, put_of(KEY_A, "x"))
self.assertEqual((authority.requests[0].shard_id, authority.requests[0].shard_epoch), (SHARD_ID, 1))
def test_grant_all_is_an_explicit_choice_that_grants_every_write(self) -> None:
server = self.build(CultMeshGrantAllWrites())
self.send(server, put_of(KEY_A, "peer"))
self.assertEqual(self.text_of(server, KEY_A), "peer")
if __name__ == "__main__":
unittest.main()

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
import msgpack import msgpack
from cultcache_py import define_database_entry_type from cultcache_py import define_database_entry_type
from cultmesh_py import create_node from cultmesh_py import CultMeshGrantAllWrites, create_node
from cultmesh_py.server import CultMeshLocalServer from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import ( from cultnet_py import (
CultNetRudpSendOptions, CultNetRudpSendOptions,
@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase):
node = create_node(runtime_id="rudp-generation") node = create_node(runtime_id="rudp-generation")
node.database.register_document(NOTE) node.database.register_document(NOTE)
# Replies stay whole datagrams here, so each server packet the pump reads is one message. # Replies stay whole datagrams here, so each server packet the pump reads is one message.
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000) self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000, write_authority=CultMeshGrantAllWrites())
self.server.start() self.server.start()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0)) self.sock.bind(("127.0.0.1", 0))

View file

@ -0,0 +1,69 @@
"""A server asked for any port finds a TCP+UDP pair; one asked for a port gets that port or an honest failure."""
from __future__ import annotations
import errno
import socket
import threading
import unittest
from unittest.mock import patch
from cultmesh_py import create_node
from cultmesh_py.server import CultMeshLocalServer
def server(**kwargs) -> CultMeshLocalServer:
return CultMeshLocalServer(node=create_node(runtime_id="bind-test"), host="127.0.0.1", **kwargs)
class ServerBindTests(unittest.TestCase):
def test_port_zero_retries_with_a_fresh_port_when_another_process_holds_the_udp_number(self) -> None:
real_bind = socket.socket.bind
refused: list[int] = []
def bind(sock: socket.socket, address) -> None:
# The first two UDP binds find the number taken, as a UDP listener of another process would.
if sock.type == socket.SOCK_DGRAM and len(refused) < 2:
refused.append(address[1])
raise OSError(errno.EADDRINUSE, "Address already in use")
real_bind(sock, address)
with patch.object(socket.socket, "bind", bind):
started = server().start()
try:
self.assertEqual(len(refused), 2)
self.assertNotIn(started.port, refused[:1], "the retry used a fresh TCP port")
probe = socket.create_connection(("127.0.0.1", started.port), timeout=2.0)
probe.close()
finally:
started.stop()
def test_an_explicit_port_whose_udp_number_is_taken_fails_and_leaves_nothing_running(self) -> None:
holder = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
holder.bind(("127.0.0.1", 0))
port = holder.getsockname()[1]
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
free.bind(("127.0.0.1", port)) # the TCP number is free: only the UDP one is taken
finally:
free.close()
threads_before = threading.active_count()
try:
failing = server(port=port)
with self.assertRaises(OSError) as raised:
failing.start()
self.assertEqual(raised.exception.errno, errno.EADDRINUSE)
self.assertIsNone(failing._socket)
self.assertEqual(threading.active_count(), threads_before, "no accept thread was left running")
# The TCP number was released, not left listening.
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
free.bind(("127.0.0.1", port))
finally:
free.close()
finally:
holder.close()
if __name__ == "__main__":
unittest.main()

View file

@ -82,7 +82,10 @@ pub struct CultMeshRudpDocumentPublishOptions {
pub target: SocketAddr, pub target: SocketAddr,
pub runtime_id: String, pub runtime_id: String,
pub connect_timeout: Duration, pub connect_timeout: Duration,
/// How long the acknowledgement of every put may take.
pub flush_timeout: Duration, pub flush_timeout: Duration,
/// How long after the acknowledgement a refusal may still arrive; a success frame ends the wait early.
pub result_window: Duration,
pub poll_interval: Duration, pub poll_interval: Duration,
pub resend_delay_ms: u64, pub resend_delay_ms: u64,
pub source_agent_id: Option<String>, pub source_agent_id: Option<String>,
@ -149,6 +152,7 @@ impl Default for CultMeshRudpDocumentPublishOptions {
runtime_id: "cultmesh-rudp-document-publisher".to_string(), runtime_id: "cultmesh-rudp-document-publisher".to_string(),
connect_timeout: Duration::from_secs(3), connect_timeout: Duration::from_secs(3),
flush_timeout: Duration::from_millis(300), flush_timeout: Duration::from_millis(300),
result_window: Duration::from_millis(500),
poll_interval: Duration::from_millis(10), poll_interval: Duration::from_millis(10),
resend_delay_ms: 50, resend_delay_ms: 50,
source_agent_id: None, source_agent_id: None,
@ -865,6 +869,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
/// fixed refusals a CultMesh catalog sends; any other text could carry /// fixed refusals a CultMesh catalog sends; any other text could carry
/// anything, so it is left out. /// anything, so it is left out.
fn recognised_refusal(text: &str) -> String { fn recognised_refusal(text: &str) -> String {
let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text);
if REFUSAL_TEXTS.contains(&text) { if REFUSAL_TEXTS.contains(&text) {
format!(": {text}") format!(": {text}")
} else { } else {
@ -881,77 +886,30 @@ fn unix_millis() -> u128 {
/// Publish one raw CultNet schema message to a RUDP catalog. /// Publish one raw CultNet schema message to a RUDP catalog.
/// ///
/// Each call owns a fresh connection epoch and returns only after the catalog /// A put completes on the catalog's result frame, never on the transport
/// acknowledges reliable application admission. Against a Rust CultMesh /// acknowledgement: an ack proves delivery, not admission. A refusal (a
/// catalog, which never acknowledges a message it refuses, a refusal is /// `cultnet.error.v0` frame) returns an error in either order against the ack,
/// returned as an error, naming the refusal when it is one of the fixed texts /// naming the refusal when it is one of the fixed texts a catalog sends. A
/// such a catalog sends. A catalog that acknowledges a message on receipt and /// catalog that admits a put sends no success frame, so after the ack the call
/// refuses it afterwards (the TypeScript document server does) is reported as /// waits `result_window` for a refusal and reports the put as admitted when
/// having admitted it: the acknowledgement arrives first. /// none comes (interim until the catalog sends a success frame; a success
/// frame ends the wait early). Each call owns a fresh connection epoch.
pub fn publish_cultnet_message_to_rudp_catalog( pub fn publish_cultnet_message_to_rudp_catalog(
message: &cultnet_rs::CultNetMessage, message: &cultnet_rs::CultNetMessage,
options: CultMeshRudpDocumentPublishOptions, options: CultMeshRudpDocumentPublishOptions,
) -> Result<()> { ) -> Result<()> {
if options.runtime_id.trim().is_empty() { publish_cultnet_messages_to_rudp_catalog(std::slice::from_ref(message), options)
anyhow::bail!("runtime_id must be non-empty");
}
let mut client = connect_rudp_client(
options.target,
&options.runtime_id,
options.connect_timeout,
options.poll_interval,
options.resend_delay_ms,
"cultmesh-rudp-document-publisher",
)?;
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
let receipt = client.send_reliable("schema", payload)?;
let flush_deadline = Instant::now() + options.flush_timeout;
while Instant::now() < flush_deadline {
if let Some(frame) = client.receive_once()? {
if frame.channel_id == "schema" {
if let Ok(cultnet_rs::CultNetMessage::Error { error, .. }) =
decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)
{
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
}
}
match client.reliable_send_status(&receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {
let _ = client.disconnect(b"document-acknowledged".to_vec());
return Ok(());
}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => {}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
}
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
} }
/// Publish a batch of raw CultNet schema messages to a RUDP catalog. /// Publish a batch of raw CultNet schema messages to a RUDP catalog on one
/// connection.
/// ///
/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for /// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]:
/// per-message application-level acknowledgement, this sends every message on /// every message acknowledged, then no refusal frame within `result_window`
/// one connection and waits only for a transport-level `flush_reliable`. It /// (or one success frame per message). A refusal of any message in the batch
/// trades per-receipt confirmation for batch throughput; callers that need a /// fails the whole call; the batch cannot hide it. The catalog's answers do not
/// stronger delivery guarantee should use the singular publish path instead. /// name the message they answer, so a failed batch does not say which message
/// was refused or whether the others were admitted.
pub fn publish_cultnet_messages_to_rudp_catalog( pub fn publish_cultnet_messages_to_rudp_catalog(
messages: &[cultnet_rs::CultNetMessage], messages: &[cultnet_rs::CultNetMessage],
options: CultMeshRudpDocumentPublishOptions, options: CultMeshRudpDocumentPublishOptions,
@ -968,11 +926,62 @@ pub fn publish_cultnet_messages_to_rudp_catalog(
"cultmesh-rudp-document-publisher", "cultmesh-rudp-document-publisher",
)?; )?;
let mut receipts = Vec::with_capacity(messages.len());
for message in messages { for message in messages {
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
client.send("schema", payload)?; receipts.push(client.send_reliable("schema", payload)?);
}
let ack_deadline = Instant::now() + options.flush_timeout;
let mut acknowledged_at: Option<Instant> = None;
let mut successes = 0;
loop {
while let Some(frame) = client.receive_once()? {
if frame.channel_id != "schema" {
continue;
}
match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
Ok(cultnet_rs::CultNetMessage::Error { error, .. }) => {
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
Ok(_) => successes += 1,
Err(_) => {}
}
}
if acknowledged_at.is_none() {
let mut all_acknowledged = true;
for receipt in &receipts {
match client.reliable_send_status(receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => all_acknowledged = false,
}
}
if all_acknowledged {
acknowledged_at = Some(Instant::now());
} else if Instant::now() >= ack_deadline {
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
}
}
if let Some(acknowledged_at) = acknowledged_at {
if successes >= messages.len() || acknowledged_at.elapsed() >= options.result_window {
let _ = client.disconnect(b"document-completed".to_vec());
return Ok(());
}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
} }
client.flush_reliable(options.flush_timeout)
} }
fn connect_rudp_client( fn connect_rudp_client(
@ -1450,6 +1459,141 @@ mod tests {
Ok((target, stop, catalog)) Ok((target, stop, catalog))
} }
/// A catalog that acknowledges every reliable message it receives and refuses it with a typed
/// write_not_authorized, the acknowledgement either before the refusal or after it (the second packet
/// follows the first by 100 ms). The refusal carries no acknowledgement of its own.
/// What the stub catalog sends for a put, and in which order against the transport ack.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CatalogReply {
RefusalThenAck,
AckThenRefusal,
AckThenSuccess,
}
fn acknowledging_refusing_catalog(
reply: CatalogReply,
) -> Result<(
SocketAddr,
std::sync::Arc<std::sync::atomic::AtomicBool>,
thread::JoinHandle<Result<()>>,
)> {
use cultnet_rs::{
CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession,
CultNetRudpSessionOptions, decode_rudp_packet, encode_rudp_packet,
};
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_read_timeout(Some(Duration::from_millis(5)))?;
let target = socket.local_addr()?;
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let catalog_stop = stop.clone();
let catalog = thread::spawn(move || -> Result<()> {
let mut session: Option<CultNetRudpSession> = None;
let mut wire = vec![0_u8; 65_535];
while !catalog_stop.load(std::sync::atomic::Ordering::SeqCst) {
let Ok((bytes, peer)) = socket.recv_from(&mut wire) else {
continue;
};
let Ok(packet) = decode_rudp_packet(&wire[..bytes]) else {
continue;
};
if packet.packet_type == CultNetRudpPacketType::Connect {
let mut accepted = CultNetRudpSession::new(CultNetRudpSessionOptions {
connection_id: packet.connection_id,
initial_sequence: None,
resend_delay_ms: 10_000,
max_pending_reliable_packets: Some(64),
});
let accept = accepted.accept_connect(&packet, 0, Vec::new())?;
socket.send_to(&encode_rudp_packet(&accept)?, peer)?;
session = Some(accepted);
continue;
}
let Some(session) = session.as_mut() else {
continue;
};
let Ok(received) = session.receive(&packet, 1) else {
continue;
};
if !packet.reliable || received.delivered.is_empty() {
continue;
}
let ack = session.create_ack_for_received(packet.sequence);
let refusal = encode_cultnet_message_to_vec(
&if reply == CatalogReply::AckThenSuccess {
cultnet_rs::CultNetMessage::SnapshotResponseRaw {
message_id: "completion".into(),
documents: Vec::new(),
}
} else {
cultnet_rs::CultNetMessage::Error {
error: "write_not_authorized: the catalog refused the document".into(),
code: Some(cultnet_rs::CultNetErrorCode::WriteNotAuthorized),
details: None,
}
},
CultNetWireContract::CultNetSchemaV0,
)?;
let options = CultNetRudpSendOptions {
reliable: false,
ordered: false,
sequenced: false,
now_ms: 2,
reliable_expire_after_ms: None,
};
let mut refusals = session.send_many("schema", refusal, options, Some(1200))?;
for refusal in &mut refusals {
refusal.ack = 0;
refusal.ack_mask = 0;
}
let send_refusals = |socket: &UdpSocket| -> Result<()> {
for refusal in &refusals {
socket.send_to(&encode_rudp_packet(refusal)?, peer)?;
}
Ok(())
};
if reply == CatalogReply::RefusalThenAck {
send_refusals(&socket)?;
thread::sleep(Duration::from_millis(100));
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
} else {
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
thread::sleep(Duration::from_millis(100));
send_refusals(&socket)?;
}
}
Ok(())
});
Ok((target, stop, catalog))
}
fn publish_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions {
CultMeshRudpDocumentPublishOptions {
target,
connect_timeout: Duration::from_secs(2),
flush_timeout: Duration::from_secs(3),
poll_interval: Duration::from_millis(2),
resend_delay_ms: 5,
..CultMeshRudpDocumentPublishOptions::default()
}
}
fn refused_note() -> cultnet_rs::CultNetMessage {
cultnet_rs::CultNetMessage::DocumentPutRaw {
message_id: "completion".into(),
document: cultnet_rs::CultNetRawDocumentRecord {
schema_id: "cultmesh.test.raw.v0".into(),
record_key: "tests:completion:a".into(),
stored_at: "2026-11-08T00:00:00Z".into(),
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
payload: vec![0xc0],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
},
}
}
/// A catalog's refusal text is quoted only when it is one of the fixed /// A catalog's refusal text is quoted only when it is one of the fixed
/// refusals a CultMesh catalog sends. Any other text, which could carry /// refusals a CultMesh catalog sends. Any other text, which could carry
/// anything, stays out of the publisher's error. /// anything, stays out of the publisher's error.
@ -1900,4 +2044,63 @@ mod tests {
assert_eq!(lease.bytes(), &[1, 2, 3]); assert_eq!(lease.bytes(), &[1, 2, 3]);
Ok(()) Ok(())
} }
// Heimdall's invariant: no client treats a reliable-transport ack as write admission. The catalog here
// acknowledges the put and refuses it, the refusal arriving before or after the ack.
#[test]
fn a_publish_whose_refusal_arrives_before_the_ack_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::RefusalThenAck)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_publish_whose_ack_arrives_before_the_refusal_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenRefusal)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_batch_publish_is_not_complete_on_the_ack_of_refused_puts() -> Result<()> {
for reply in [CatalogReply::AckThenRefusal, CatalogReply::RefusalThenAck] {
let (target, stop, catalog) = acknowledging_refusing_catalog(reply)?;
let result = publish_cultnet_messages_to_rudp_catalog(&[refused_note()], publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
assert!(result.is_err(), "a refused put was reported as published");
}
Ok(())
}
#[test]
fn a_success_frame_completes_the_publish_without_waiting_out_the_result_window() -> Result<()> {
for batch in [false, true] {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenSuccess)?;
let options = CultMeshRudpDocumentPublishOptions {
result_window: Duration::from_secs(30),
..publish_options(target)
};
let started = Instant::now();
let result = if batch {
publish_cultnet_messages_to_rudp_catalog(&[refused_note()], options)
} else {
publish_cultnet_message_to_rudp_catalog(&refused_note(), options)
};
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
result?;
assert!(started.elapsed() < Duration::from_secs(10), "batch={batch}: waited out the window");
}
Ok(())
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,27 @@
# Changelog # Changelog
All notable changes to `cultmesh-ts` are recorded here, following All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## 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 ### 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. - `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. - `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.
- `createRudpDocumentServer` takes `writeAuthorityTimeoutMs` (default `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`, 5000; a value outside 1..2147483647 throws `RangeError`): a write authority whose `decide()` has not settled by then refuses the write like one that throws (`write_not_authorized`, and `onError` hears of it), so a hung authority no longer holds up the session's later frames.
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a typed `message_invalid` refusal (`details.field` is `message`). A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `message_invalid` naming that field. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`. - Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
### Added ### Added
- `createRudpDocumentServer` ends a session after `CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES` (8) consecutive undecodable frames or consecutive deletes the write authority refuses; fewer are answered and the session is kept. Any decoded frame resets the undecodable run, any decoded non-refused-delete frame resets the delete run. The same cap holds in every runtime.
- `CultMeshCatalogValue<TDocument, TSchema>`, exported. - `CultMeshCatalogValue<TDocument, TSchema>`, exported.
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type. - `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.

View file

@ -1,6 +1,6 @@
# CultMesh TS # CultMesh TS
`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local `@gamecult/cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
native CultMesh stream implementation used by Mimir and Fensalir; this package native CultMesh stream implementation used by Mimir and Fensalir; this package
mirrors the typed declaration and negotiation shapes without owning the hot mirrors the typed declaration and negotiation shapes without owning the hot
@ -21,7 +21,7 @@ WASM route. Runtime code can ask for a whole XY rect, then adapt it to the
current wire schema at the edge: current wire schema at the edge:
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
const request = CultMesh.viewportRequest(viewport, [3, 8]); const request = CultMesh.viewportRequest(viewport, [3, 8]);
@ -215,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render);
``` ```
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>(); const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
const stopWatching = verses.watch((verse) => { const stopWatching = verses.watch((verse) => {
@ -274,7 +274,7 @@ const noteShards = shards.list({
}); });
``` ```
Schema and shard catalog factories delegate to `cultnet-ts`; the branded Schema and shard catalog factories delegate to `@gamecult/cultnet-ts`; the branded
`CultMesh` entrypoint does not create a second owner for schema discovery or `CultMesh` entrypoint does not create a second owner for schema discovery or
topology truth. topology truth.
@ -290,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details.
import { import {
CultMeshProviderRudpTransport, CultMeshProviderRudpTransport,
CultMeshProviderSession, CultMeshProviderSession,
} from "cultmesh-ts"; } from "@gamecult/cultmesh-ts";
const odinTransport = new CultMeshProviderRudpTransport({ const odinTransport = new CultMeshProviderRudpTransport({
endpoint: "rudp://127.0.0.1:17871", endpoint: "rudp://127.0.0.1:17871",
@ -345,9 +345,11 @@ evidence for the requested provider identity.
## RUDP Helpers ## RUDP Helpers
`createRudpDocumentServer` refuses every peer put and delete unless given a `writeAuthority` (`new CultMeshGrantAllWrites()` accepts all). The authority is asked before a write's payload is sized or decoded and before any handler runs, and a refusal is the typed `write_not_authorized`. Residual: ungranted peers can still distinguish registered from unregistered schemas by timing.
Node runtimes can build the shared CultNet reliable-UDP transport from the Node runtimes can build the shared CultNet reliable-UDP transport from the
branded CultMesh entrypoint while the transport semantics stay owned by branded CultMesh entrypoint while the transport semantics stay owned by
`cultnet-ts`: `@gamecult/cultnet-ts`:
```ts ```ts
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040); const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
@ -472,7 +474,7 @@ The split is deliberate:
The mirrored API shape is `CultMeshStreamCatalog`: The mirrored API shape is `CultMeshStreamCatalog`:
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const streams = CultMesh.createStreamCatalog(); const streams = CultMesh.createStreamCatalog();
const stopWatchingStreams = streams.watch((stream) => { const stopWatchingStreams = streams.watch((stream) => {
@ -533,7 +535,7 @@ nonlocal fallback.
## Content Chunks over HTTP(S) ## Content Chunks over HTTP(S)
`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD` `@gamecult/cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the `<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
handler reads only the query. Every chunk is hashed against its name before a handler reads only the query. Every chunk is hashed against its name before a
byte is sent, so a corrupt or swapped file answers 500 and never leaves the byte is sent, so a corrupt or swapped file answers 500 and never leaves the
@ -550,7 +552,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma
```ts ```ts
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createServer } from "node:https"; import { createServer } from "node:https";
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content"; import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "@gamecult/cultmesh-ts/content";
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), { const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash), onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),

View file

@ -1,5 +1,5 @@
{ {
"name": "cultmesh-ts", "name": "@gamecult/cultmesh-ts",
"version": "0.2.0", "version": "0.2.0",
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
"main": "dist/index.js", "main": "dist/index.js",
@ -48,8 +48,8 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.3.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -2,7 +2,7 @@ import { constants } from "node:fs";
import { open } from "node:fs/promises"; import { open } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { join } from "node:path"; import { join } from "node:path";
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "cultnet-ts/content"; import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "@gamecult/cultnet-ts/content";
/** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */ /** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */
export interface ContentChunkRequest { method: string; url: string; } export interface ContentChunkRequest { method: string; url: string; }

View file

@ -1,5 +1,5 @@
import { createSocket, type Socket } from "node:dgram"; import { createSocket, type Socket } from "node:dgram";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultCache, CultCache,
@ -20,6 +20,13 @@ import {
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportConnection,
rudpClientBindHost, rudpClientBindHost,
CultNetSchemaCatalog, CultNetSchemaCatalog,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
WriteTracker,
helloAdvertisesWriteReceipts,
resultWindowMs,
cultNetMessageInvalidError,
cultNetTextError,
cultNetWriteNotAuthorizedError,
CultNetShardCatalog, CultNetShardCatalog,
cultNetBuiltinSchemaRegistry, cultNetBuiltinSchemaRegistry,
decodeRudpPacket, decodeRudpPacket,
@ -31,6 +38,7 @@ import {
sendRudpDatagram, sendRudpDatagram,
type CultNetErrorMessage, type CultNetErrorMessage,
type CultNetDocumentBinding, type CultNetDocumentBinding,
type CultNetDocumentDeleteMessage,
type CultNetMessage, type CultNetMessage,
type CultNetOperationRequestMessage, type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage, type CultNetOperationResponseMessage,
@ -40,7 +48,9 @@ import {
type CultNetSchemaCatalogOptions, type CultNetSchemaCatalogOptions,
type CultNetSnapshotResponseRawMessage, type CultNetSnapshotResponseRawMessage,
type CultNetWireContract, type CultNetWireContract,
} from "cultnet-ts"; type WriteSettlement,
type WriteUnconfirmedReason,
} from "@gamecult/cultnet-ts";
import { import {
CultMeshQuicRealtimeConnector, CultMeshQuicRealtimeConnector,
@ -3991,6 +4001,63 @@ export type CultMeshRudpDocumentPut = {
| { readonly decoded: false; readonly payload: DocumentPayload } | { readonly decoded: false; readonly payload: DocumentPayload }
); );
/** The peer a write arrived from: the datagram's source, nothing the peer claimed. */
export interface CultMeshRemoteWritePeer {
address: string;
family: string;
port: number;
}
export type CultMeshRemoteWriteOperation = "put" | "delete";
/**
* One peer write as the authority sees it. `schemaId` is the canonical id the registry resolved for the write (the id
* as sent when no binding takes it), never an alias the peer used when a binding resolves it. `shardId` and
* `shardEpoch` are as a put carried them, `null` when it sent none (always `null` for a delete, which has none); a
* put carrying either with the wrong type is refused before any authority is asked.
*/
export interface CultMeshRemoteWriteRequest {
peer: CultMeshRemoteWritePeer;
operation: CultMeshRemoteWriteOperation;
schemaId: string;
recordKey: string;
shardId: string | null;
shardEpoch: number | null;
}
export interface CultMeshRemoteWriteDecision {
granted: boolean;
/** Told to the refused peer after `write_not_authorized:`; keep it free of anything secret. */
reason?: string;
}
/**
* The one question a document server asks before it applies a peer's put or delete (C# `ICultNetRemoteWriteAuthority`).
* A server with no authority refuses every peer write; one whose authority throws, rejects or answers anything but
* `granted: true` refuses too.
*/
export interface CultMeshRemoteWriteAuthority {
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
}
/**
* Grants every peer write. For a host that has already authenticated its peers, or a test rig: it is an explicit
* choice because serving without an authority refuses writes.
*/
export class CultMeshGrantAllWrites implements CultMeshRemoteWriteAuthority {
decide(): CultMeshRemoteWriteDecision {
return { granted: true };
}
}
/** A delete as `onDocumentDeleteRaw` receives it, after the authority granted it. */
export interface CultMeshRudpDocumentDelete {
messageId: string;
schemaId: string;
recordKey: string;
remote: CultMeshRemoteWritePeer;
}
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> { export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
binding: CultNetDocumentBinding<TDefinition>; binding: CultNetDocumentBinding<TDefinition>;
recordKey: string; recordKey: string;
@ -4030,10 +4097,24 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
documents: CultNetDocumentRegistry; documents: CultNetDocumentRegistry;
getCache?: () => Promise<CultCache> | CultCache; getCache?: () => Promise<CultCache> | CultCache;
onError?: (error: Error) => void; onError?: (error: Error) => void;
/**
* Who may put and delete. Without it every peer put and delete is refused with a typed `write_not_authorized`
* (and `onDocumentPutRaw` is never called); pass `new CultMeshGrantAllWrites()` to accept them all. The authority is
* asked before the document is sized, resolved or decoded.
*/
writeAuthority?: CultMeshRemoteWriteAuthority;
/**
* How long the write authority has to answer before the write is refused (default
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`). A `decide()` that never settles is a refusal like any
* other, so it cannot hold up the frames that follow it on the session.
*/
writeAuthorityTimeoutMs?: number;
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
| CultMeshRudpDocumentReceipt | CultMeshRudpDocumentReceipt
| void | void
| Promise<CultMeshRudpDocumentReceipt | void>; | Promise<CultMeshRudpDocumentReceipt | void>;
/** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */
onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise<void>;
onOperationRequest?: ( onOperationRequest?: (
request: CultNetOperationRequestMessage, request: CultNetOperationRequestMessage,
session: CultMeshRudpServerSession, session: CultMeshRudpServerSession,
@ -4043,6 +4124,19 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
sessionTimeoutMs?: number; sessionTimeoutMs?: number;
} }
/** A write authority that has not answered in this long has refused the write. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS = 5_000;
/**
* How many consecutive refused frames of one kind a `createRudpDocumentServer` session may send before it is
* ended: undecodable frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any
* decoded frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
* frame that reaches this number is answered too, then the session ends. The same number is the cap in every runtime.
*/
export const CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES = 8;
/** How long a session whose capped frame was answered waits for the peer to acknowledge the answers before it ends anyway. */
const CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS = 2000;
/** Replies larger than one fragment are split into fragments of at most this many bytes. */ /** Replies larger than one fragment are split into fragments of at most this many bytes. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200; export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
@ -4095,7 +4189,64 @@ export interface CultMeshRudpDocumentServer {
close(): void; close(): void;
} }
/**
* A write the peer refused: the typed `cultnet.error.v0` it answered with (`write_not_authorized`,
* `message_invalid`, ...), whichever of the transport ack and the result frame came first.
*/
export class CultMeshRudpWriteRefusedError extends Error {
readonly code: string | undefined;
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;
this.response = response;
}
}
/**
* 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 { export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
/**
* 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; messageId?: string;
sourceRuntimeId?: string; sourceRuntimeId?: string;
sourceAgentId?: string; sourceAgentId?: string;
@ -5459,6 +5610,11 @@ export class CultMesh {
closed: boolean; closed: boolean;
/** A packet it owed could never be sent, so the session ended. */ /** A packet it owed could never be sent, so the session ended. */
unsendable: boolean; unsendable: boolean;
/** Consecutive undecodable frames, and consecutive deletes the authority refused. */
unreadableInARow: number;
refusedDeletesInARow: number;
/** Set once the peer's capped frame is answered: the session ends when the answers are acknowledged, or at this time. */
endingDeadlineMs?: number;
work: Promise<void>; work: Promise<void>;
}; };
const sessions = new Map<string, SessionRecord>(); const sessions = new Map<string, SessionRecord>();
@ -5466,6 +5622,11 @@ export class CultMesh {
let sendFailures = 0; let sendFailures = 0;
const resendPollMs = Math.max(10, options.resendPollMs ?? 25); const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000); const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
const decideTimeoutMs = options.writeAuthorityTimeoutMs ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS;
// A timer past 2^31-1 ms (or NaN) fires after 1 ms in Node, which would refuse every write that is not instant.
if (!(decideTimeoutMs > 0 && decideTimeoutMs <= 0x7fffffff)) {
throw new RangeError("CultMesh RUDP document server writeAuthorityTimeoutMs must be greater than zero and at most 2147483647.");
}
const wireContract = options.wireContract ?? "cultnet.schema.v0"; const wireContract = options.wireContract ?? "cultnet.schema.v0";
const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES; const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES;
if (!(maxFragmentBytes > 0)) { if (!(maxFragmentBytes > 0)) {
@ -5508,6 +5669,8 @@ export class CultMesh {
connectPayload, connectPayload,
closed: false, closed: false,
unsendable: false, unsendable: false,
unreadableInARow: 0,
refusedDeletesInARow: 0,
session: new CultNetRudpSession({ session: new CultNetRudpSession({
connectionId, connectionId,
initialSequence: options.initialSequence, initialSequence: options.initialSequence,
@ -5572,15 +5735,36 @@ export class CultMesh {
record: SessionRecord, record: SessionRecord,
payload: Uint8Array, payload: Uint8Array,
): Promise<void> { ): Promise<void> {
if (record.closed) return; // Nothing after the capped frame is served while its answer is delivered.
const message = parseCultNetMessage(decodeWireValue(payload), wireContract); if (record.closed || record.endingDeadlineMs !== undefined) return;
let decoded: unknown;
let message: CultNetMessage;
try {
decoded = decodeWireValue(payload);
message = parseCultNetMessage(decoded, wireContract);
} catch (error) {
// A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so
// an ungranted peer hears the same refusal for it as for any other write, not a parse error.
const write = readableWrite(decoded);
record.unreadableInARow += 1;
if (decoded !== undefined && !write) {
// It decodes but is no message the parser takes (an unknown schemaVersion, a non-map) and is no write the
// authority could judge: answered and counted like any other unreadable frame.
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the message."));
} else if (!await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) {
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
}
if (record.unreadableInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.unreadableInARow = 0;
if (message.schemaVersion !== "cultnet.document_delete.v0") record.refusedDeletesInARow = 0;
switch (message.schemaVersion) { switch (message.schemaVersion) {
case "cultnet.snapshot_request.v0": { case "cultnet.snapshot_request.v0": {
if (!options.getCache) { if (!options.getCache) {
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no cache for snapshot requests."));
schemaVersion: "cultnet.error.v0",
error: "CultMesh RUDP document server has no cache for snapshot requests.",
});
return; return;
} }
let reply: CultNetMessage; let reply: CultNetMessage;
@ -5609,11 +5793,32 @@ export class CultMesh {
sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message)); sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
return; return;
case "cultnet.document_put_raw.v0": case "cultnet.document_put_raw.v0":
{
// The parser passes shard fields of any type through a put. The authority is asked first, with the shard
// fields it can trust (null when wrong-typed), so an ungranted peer hears only the refusal; a granted peer
// whose shard fields are wrong-typed is then refused as malformed.
const { shardId, shardEpoch } = message as { shardId?: unknown; shardEpoch?: unknown };
const malformedShard =
(shardId !== undefined && typeof shardId !== "string") ||
(shardEpoch !== undefined && !Number.isSafeInteger(shardEpoch));
if (await writeRefused(
record, "put", message.document.schemaId, message.document.recordKey,
malformedShard ? null : (shardId as string | undefined) ?? null,
malformedShard ? null : (shardEpoch as number | undefined) ?? null,
)) return;
if (malformedShard) {
sendSchemaMessage(record, cultNetMessageInvalidError(
typeof shardId !== "undefined" && typeof shardId !== "string" ? "shardId" : "shardEpoch",
"Raw put messages must carry a string shardId and an integer shardEpoch.",
));
return;
}
}
if (options.onDocumentPutRaw) { if (options.onDocumentPutRaw) {
const unservable = unservablePut(message); const unservable = unservablePut(message);
if (unservable) { if (unservable) {
reportError(unservable); reportError(unservable);
sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message }); sendSchemaMessage(record, cultNetTextError(unservable.message));
return; return;
} }
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents)); const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents));
@ -5623,32 +5828,119 @@ export class CultMesh {
} catch (error) { } catch (error) {
// The put is stored: the peer must hear that its receipt is not coming. // The put is stored: the peer must hear that its receipt is not coming.
reportError(error); reportError(error);
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError(`CultMesh RUDP document server stored a put but could not send its receipt.`));
schemaVersion: "cultnet.error.v0",
error: `CultMesh RUDP document server stored a put but could not send its receipt.`,
});
} }
} }
} }
return; return;
case "cultnet.document_delete.v0": {
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) {
record.refusedDeletesInARow += 1;
if (record.refusedDeletesInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.refusedDeletesInARow = 0;
if (!options.onDocumentDeleteRaw) {
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler."));
return;
}
await options.onDocumentDeleteRaw({
messageId: message.messageId,
schemaId: message.schemaId,
recordKey: message.recordKey,
remote: { ...record.remote },
});
return;
}
case "cultnet.operation_request.v0": case "cultnet.operation_request.v0":
if (!options.onOperationRequest) { if (!options.onOperationRequest) {
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no operation handler."));
schemaVersion: "cultnet.error.v0",
error: "CultMesh RUDP document server has no operation handler.",
});
return; return;
} }
sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record)));
return; return;
default: default:
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError(`Unsupported CultMesh RUDP document request ${message.schemaVersion}.`));
schemaVersion: "cultnet.error.v0",
error: `Unsupported CultMesh RUDP document request ${message.schemaVersion}.`,
});
} }
} }
function clipUtf8(text: string, maxBytes: number): string {
const bytes = Buffer.from(text, "utf8");
return bytes.byteLength <= maxBytes ? text : new TextDecoder().decode(bytes.subarray(0, maxBytes)).replace(/\uFFFD+$/, "");
}
/** The put or delete a decoded frame claims to be, with the ids it carries when they are strings. */
function readableWrite(value: unknown): { operation: CultMeshRemoteWriteOperation; schemaId: string; recordKey: string } | undefined {
if (typeof value !== "object" || value === null) return undefined;
const frame = value as { schemaVersion?: unknown; schemaId?: unknown; recordKey?: unknown; document?: unknown };
const str = (v: unknown): string => (typeof v === "string" ? v : "");
if (frame.schemaVersion === "cultnet.document_delete.v0") {
return { operation: "delete", schemaId: str(frame.schemaId), recordKey: str(frame.recordKey) };
}
if (frame.schemaVersion === "cultnet.document_put_raw.v0") {
const document = (typeof frame.document === "object" && frame.document !== null ? frame.document : {}) as { schemaId?: unknown; recordKey?: unknown };
return { operation: "put", schemaId: str(document.schemaId), recordKey: str(document.recordKey) };
}
return undefined;
}
/**
* Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and
* answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused.
* No authority refuses; an authority that throws, rejects or does not say `granted: true` refuses too (fail
* closed), and a failure is reported without quoting it.
*/
async function writeRefused(
record: SessionRecord,
operation: CultMeshRemoteWriteOperation,
schemaId: string,
recordKey: string,
shardId: string | null,
shardEpoch: number | null,
): Promise<boolean> {
let reason: string | undefined;
const authority = options.writeAuthority;
if (!authority) {
reason = "no remote write authority is configured";
} else {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timedOut = Symbol("decide timeout");
const decision = await Promise.race([
Promise.resolve().then(() => authority.decide({
peer: { ...record.remote },
operation,
schemaId: options.documents.canonicalSchemaId(schemaId) ?? schemaId,
recordKey,
shardId,
shardEpoch,
})),
new Promise<typeof timedOut>((resolve) => { timer = setTimeout(() => resolve(timedOut), decideTimeoutMs); }),
]);
if (decision === timedOut) {
reportError(new Error(`CultMesh remote write authority did not answer within ${decideTimeoutMs} ms; the write was refused.`));
reason = "the write authority timed out";
} else {
if (decision?.granted === true) return false;
reason = typeof decision?.reason === "string" ? decision.reason : undefined;
}
} catch (error) {
reportError(new Error(`CultMesh remote write authority failed (${error instanceof Error ? error.name : typeof error}); the write was refused.`));
reason = "the write authority failed";
} finally {
clearTimeout(timer);
}
}
try {
// The key is the peer's and the reason the authority's: both are clipped so the refusal fits `maxPayloadBytes`
// whatever they are (the peer is told the clipped key; a `maxPayloadBytes` under about 512 can still not carry it).
sendSchemaMessage(record, cultNetWriteNotAuthorizedError(clipUtf8(recordKey, 64), reason === undefined ? undefined : clipUtf8(reason, 96)));
} catch (error) {
reportError(error);
}
return true;
}
function publicSession(record: SessionRecord): CultMeshRudpServerSession { function publicSession(record: SessionRecord): CultMeshRudpServerSession {
return { return {
sessionId: record.sessionId, sessionId: record.sessionId,
@ -5698,6 +5990,17 @@ export class CultMesh {
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {}); sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
} }
/**
* A session that reached the cap of consecutive refused frames: its last refusal went out like any reply
* (reliable, so a lost datagram is resent), and the session ends once the peer has acknowledged every reply or
* the grace runs out. The resend timer ends it.
*/
function endCappedSession(record: SessionRecord): void {
const key = `${record.remote.address}:${record.remote.port}`;
if (sessions.get(key) !== record) return;
record.endingDeadlineMs ??= Date.now() + CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS;
}
/** The session owes its peer a packet that can never be sent as built. */ /** The session owes its peer a packet that can never be sent as built. */
function endUnsendableSession(record: SessionRecord, error: Error): void { function endUnsendableSession(record: SessionRecord, error: Error): void {
if (record.unsendable) return; if (record.unsendable) return;
@ -5741,7 +6044,7 @@ export class CultMesh {
*/ */
function refuse(record: SessionRecord, text: string): void { function refuse(record: SessionRecord, text: string): void {
if (record.closed) return; if (record.closed) return;
const payload = encodeSchemaMessage({ schemaVersion: "cultnet.error.v0", error: text }); const payload = encodeSchemaMessage(cultNetTextError(text));
for (const packet of record.session.sendMany("schema", payload, { for (const packet of record.session.sendMany("schema", payload, {
reliable: false, reliable: false,
ordered: false, ordered: false,
@ -5831,6 +6134,11 @@ export class CultMesh {
closeSession(record); closeSession(record);
continue; continue;
} }
if (record.endingDeadlineMs !== undefined
&& (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) {
endRefusedSession(key, record);
continue;
}
for (const packet of record.session.dueResends(nowMs)) { for (const packet of record.session.dueResends(nowMs)) {
sendPacket(record, packet); sendPacket(record, packet);
} }
@ -5908,25 +6216,88 @@ export class CultMesh {
recordKey: string, recordKey: string,
value: CultCacheDocumentValue<TDefinition>, value: CultCacheDocumentValue<TDefinition>,
options: CultMeshRudpDocumentPublishOptions = {}, options: CultMeshRudpDocumentPublishOptions = {},
): Promise<void> { ): Promise<"admitted" | "sent"> {
requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(runtimeId, "runtimeId");
requireNonEmpty(recordKey, "recordKey"); requireNonEmpty(recordKey, "recordKey");
if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined.");
const registry = new CultNetDocumentRegistry([binding]); 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); const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
try { try {
peer.send(registry.createRawDocumentPutMessage( if (options.sendOnly) {
binding, try {
options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`, peer.send(put);
recordKey, await peer.flush(options.flushTimeoutMs ?? 30_000);
value, } catch {
{ throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed");
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, }
sourceAgentId: options.sourceAgentId, return "sent";
sourceRole: options.sourceRole, }
tags: options.tags, // 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;
await peer.flush(options.flushTimeoutMs ?? 30_000); 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.hello.v0") {
receiptCapable = helloAdvertisesWriteReceipts(message);
serverHello?.();
} else {
tracker?.frame(message, Date.now());
}
});
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, remaining);
wake = () => { clearTimeout(timer); resolve(); };
});
writeTracker.tick(Date.now());
}
if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response);
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
return "admitted";
} finally { } finally {
peer.close(); peer.close();
} }
@ -5945,6 +6316,7 @@ export class CultMesh {
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => { const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000); const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
const onMessage = (message: CultNetMessage): void => { const onMessage = (message: CultNetMessage): void => {
if (message.schemaVersion === "cultnet.error.v0") { cleanup(); reject(new CultMeshRudpWriteRefusedError(message)); return; }
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return; if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
try { try {
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload); const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createSocket, type Socket } from "node:dgram"; import { createSocket, type Socket } from "node:dgram";
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { encodeWireValue } from "cultnet-ts/wire"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultNetPeer, CultNetPeer,
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportConnection,
@ -9,7 +9,7 @@ import {
type CultNetOperationRequestMessage, type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage, type CultNetOperationResponseMessage,
type CultNetRawDocumentRecord, type CultNetRawDocumentRecord,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import type { import type {
CultMeshProviderCommand, CultMeshProviderCommand,

View file

@ -1,11 +1,11 @@
import { encodeDocumentPayload } from "@gamecult/cultcache-ts"; import { encodeDocumentPayload } from "@gamecult/cultcache-ts";
import { encodeWireValue } from "cultnet-ts/wire"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import type { import type {
CultNetDocumentPutRawMessage, CultNetDocumentPutRawMessage,
CultNetOperationRequestMessage, CultNetOperationRequestMessage,
CultNetOperationResponseMessage, CultNetOperationResponseMessage,
CultNetRawDocumentRecord, CultNetRawDocumentRecord,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import type { CultMeshRudpServerSession } from "./index"; import type { CultMeshRudpServerSession } from "./index";
import { import {

View file

@ -1,6 +1,6 @@
import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts"; import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import type { CultNetRawDocumentRecord } from "cultnet-ts"; import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts";
export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session"; export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session";

View file

@ -21,8 +21,8 @@ import {
trimCSharp, trimCSharp,
verifyAuthorityRoute, verifyAuthorityRoute,
type CultMeshAuthorityTrustPolicy, type CultMeshAuthorityTrustPolicy,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import type { CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
import { import {
decodeRealtimeFrame, decodeRealtimeFrame,

View file

@ -16,7 +16,7 @@
// agree with itself. // agree with itself.
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
import { isNullOrWhiteSpaceCSharp } from "cultnet-ts"; import { isNullOrWhiteSpaceCSharp } from "@gamecult/cultnet-ts";
/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */ /** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */
export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"; export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";
@ -70,7 +70,7 @@ const INT64_MAX = 2n ** 63n - 1n;
* `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is * `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is
* `string.IsNullOrWhiteSpace`, whose whitespace set is not * `string.IsNullOrWhiteSpace`, whose whitespace set is not
* `String.prototype.trim`'s, so this borrows the one TypeScript implementation * `String.prototype.trim`'s, so this borrows the one TypeScript implementation
* of that set from `cultnet-ts` rather than keeping a second reading here. * of that set from `@gamecult/cultnet-ts` rather than keeping a second reading here.
* *
* The range check has no C# counterpart because `long` gives it for free; a * The range check has no C# counterpart because `long` gives it for free; a
* `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError` * `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError`

View file

@ -0,0 +1,120 @@
// Heimdall's invariant: no client may treat a reliable-transport ack as write admission. A put completes only
// on its result frame (write_not_authorized, or the success reply). Each test serves a client a peer that
// acknowledges the put and refuses it, the ack arriving before the refusal and after it.
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 {
CultNetDocumentRegistry,
CultNetRudpSession,
cultNetWriteNotAuthorizedError,
decodeRudpPacket,
defineCultNetDocumentBinding,
encodeCultNetMessageForWire,
encodeRudpPacket,
} from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
const isTheRefusal = (error: unknown): boolean =>
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
const note = defineDocumentType({
type: "cultmesh.completion_note",
schemaId: "cultmesh.completion_note.v0",
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const binding = defineCultNetDocumentBinding({ definition: note });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type Order = "ack-then-refusal" | "refusal-then-ack";
/** A peer that acknowledges every reliable put and refuses it with write_not_authorized, in the given order. */
async function refusingPeer(connectionId: number, order: Order) {
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>();
let closed = false;
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
};
const later = (action: () => void) => { const timer = setTimeout(() => { timers.delete(timer); action(); }, 120); timers.add(timer); };
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 ack = session.createAckForReceived(packet.sequence);
const refusal = session.sendMany(
"schema",
encodeWireValue(encodeCultNetMessageForWire(cultNetWriteNotAuthorizedError("tests:completion:a"), "cultnet.schema.v0")),
{ reliable: true, ordered: true, nowMs: now },
);
if (order === "ack-then-refusal") {
send(ack, remote.port, remote.address);
later(() => refusal.forEach((p) => send(p, remote.port, remote.address)));
} else {
// The refusal carries no acknowledgement of its own; the ack follows it.
refusal.forEach((p) => send({ ...p, ack: 0, ackMask: 0 }, remote.port, remote.address));
later(() => send(ack, remote.port, remote.address));
}
}
if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
});
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
}
const orders: Order[] = ["ack-then-refusal", "refusal-then-ack"];
let nextConnection = 0x10205000;
for (const order of orders) {
test(`publishRudpDocumentOnce does not complete on the ack of a refused put (${order})`, async () => {
const peer = await refusingPeer(nextConnection, order);
try {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("completion-once", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }),
isTheRefusal,
);
} finally { peer.close(); }
});
test(`publishRudpDocumentAndWaitForReceipt reports a refused put as refused, never as success (${order})`, async () => {
const peer = await refusingPeer(nextConnection, order);
try {
await assert.rejects(
CultMesh.publishRudpDocumentAndWaitForReceipt("completion-receipt", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, binding, { resendDelayMs: 50, resendPollMs: 5, receiptTimeoutMs: 800 }),
isTheRefusal,
);
} finally { peer.close(); }
await sleep(10);
});
}
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, {
documents: new CultNetDocumentRegistry([binding]),
writeAuthority: new CultMeshGrantAllWrites(),
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 50, resendPollMs: 5,
onDocumentPutRaw: (put) => { puts.push(put.recordKey); },
});
await server.start();
try {
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

@ -19,8 +19,8 @@ import {
CultNetDocumentRegistry, CultNetDocumentRegistry,
defineCultNetDocumentBinding, defineCultNetDocumentBinding,
type CultNetPeer, type CultNetPeer,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import { CultMesh, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index"; import { CultMesh, CultMeshGrantAllWrites, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index";
// A document written in the reference's positional shape, and a plain map document beside it. Every mesh reader of a // A document written in the reference's positional shape, and a plain map document beside it. Every mesh reader of a
// payload reads it the way the cache does, so both read through the mesh. // payload reads it the way the cache does, so both read through the mesh.
@ -77,6 +77,7 @@ test("the mesh syncs a codec document from a peer snapshot and returns a codec r
const receiptBinding = defineCultNetDocumentBinding({ definition: codecReceipt }); const receiptBinding = defineCultNetDocumentBinding({ definition: codecReceipt });
const puts: unknown[] = []; const puts: unknown[] = [];
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, { const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]), documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]),
getCache: () => source.cache, getCache: () => source.cache,
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
@ -131,6 +132,7 @@ test("a RUDP put naming a schema the server has no definition for reaches the ha
const errors: Error[] = []; const errors: Error[] = [];
const puts: CultMeshRudpDocumentPut[] = []; const puts: CultMeshRudpDocumentPut[] = [];
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, { const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([known]), documents: new CultNetDocumentRegistry([known]),
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
onError: (error) => errors.push(error), onError: (error) => errors.push(error),
@ -171,6 +173,7 @@ test("a payload past the nesting limit is refused at the network doors: a RUDP p
const errors: unknown[] = []; const errors: unknown[] = [];
const puts: CultMeshRudpDocumentPut[] = []; const puts: CultMeshRudpDocumentPut[] = [];
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, { const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: registry, documents: registry,
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
onError: (error) => errors.push(error), onError: (error) => errors.push(error),

View file

@ -8,7 +8,7 @@ import { join } from "node:path";
import test, { mock } from "node:test"; import test, { mock } from "node:test";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "cultnet-ts/content"; import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "@gamecult/cultnet-ts/content";
import { closeSync, constants as fsConstants, openSync, symlinkSync } from "node:fs"; import { closeSync, constants as fsConstants, openSync, symlinkSync } from "node:fs";
import { import {

View file

@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { z } from "zod"; import { z } from "zod";
import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { import {
@ -12,6 +12,7 @@ import {
CultNetPeer, CultNetPeer,
CultNetRudpSession, CultNetRudpSession,
cultNetBuiltinSchemaRegistry, cultNetBuiltinSchemaRegistry,
cultNetTextError,
decodeRudpPacket, decodeRudpPacket,
defineCultNetDocumentBinding, defineCultNetDocumentBinding,
encodeRudpPacket, encodeRudpPacket,
@ -19,8 +20,8 @@ import {
type CultNetRudpPacket, type CultNetRudpPacket,
type CultNetMessage, type CultNetMessage,
type CultNetSnapshotResponseRawMessage, type CultNetSnapshotResponseRawMessage,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import { CultMesh, 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. // A record's payload as the bytes a peer would put on the wire.
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value)); const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
@ -2856,6 +2857,7 @@ test("CultMesh TS RUDP document server accepts raw document puts from multiple p
"cultmesh-ts-document-server", "cultmesh-ts-document-server",
connectionId, connectionId,
{ {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
bindHost: "127.0.0.1", bindHost: "127.0.0.1",
bindPort: 0, bindPort: 0,
@ -2944,6 +2946,7 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
"cultmesh-ts-odin-catalog", "cultmesh-ts-odin-catalog",
connectionId, connectionId,
{ {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
bindHost: "127.0.0.1", bindHost: "127.0.0.1",
bindPort: 0, bindPort: 0,
@ -2965,25 +2968,30 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
try { try {
await server.start(); await server.start();
await CultMesh.publishRudpDocumentOnce( // This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did.
"cultmesh-ts-muninn", await assert.rejects(
connectionId, CultMesh.publishRudpDocumentOnce(
`rudp://127.0.0.1:${server.bind.port}`, "cultmesh-ts-muninn",
defineCultNetDocumentBinding({ definition: noteDocument }), connectionId,
"note:odin", `rudp://127.0.0.1:${server.bind.port}`,
{ defineCultNetDocumentBinding({ definition: noteDocument }),
noteId: "note:odin", "note:odin",
body: "pay respects once", {
}, noteId: "note:odin",
{ body: "pay respects once",
resendDelayMs: 25, },
resendPollMs: 5, {
maxFragmentBytes: 1024, resendDelayMs: 25,
maxPendingReliablePackets: 16, resendPollMs: 5,
connectTimeoutMs: 1_000, maxFragmentBytes: 1024,
flushTimeoutMs: 25, maxPendingReliablePackets: 16,
sourceRole: "test-provider", 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"); await waitFor(() => received !== undefined, "published RUDP document");
@ -3019,6 +3027,7 @@ test("CultMesh TS returns a correlated provider receipt after a RUDP document mu
const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument }); const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument });
const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument }); const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument });
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, { const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0, documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0,
resendDelayMs: 25, resendPollMs: 5, resendDelayMs: 25, resendPollMs: 5,
onDocumentPutRaw: async (document) => ({ onDocumentPutRaw: async (document) => ({
@ -3054,6 +3063,7 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async (
"cultmesh-ts-rudp-snapshot-server", "cultmesh-ts-rudp-snapshot-server",
connectionId, connectionId,
{ {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([ documents: new CultNetDocumentRegistry([
defineCultNetDocumentBinding({ definition: noteDocument }), defineCultNetDocumentBinding({ definition: noteDocument }),
]), ]),
@ -3151,6 +3161,7 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node", async () => {
"cultmesh-ts-rudp-sync-server", "cultmesh-ts-rudp-sync-server",
connectionId, connectionId,
{ {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([ documents: new CultNetDocumentRegistry([
defineCultNetDocumentBinding({ definition: noteDocument }), defineCultNetDocumentBinding({ definition: noteDocument }),
]), ]),
@ -3232,6 +3243,7 @@ test("CultMesh TS peer snapshots never take another type's record at the key", a
"cultmesh-ts-rudp-foreign-snapshot-server", "cultmesh-ts-rudp-foreign-snapshot-server",
connectionId, connectionId,
{ {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry([ documents: new CultNetDocumentRegistry([
defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }), defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }),
defineCultNetDocumentBinding({ definition: foreignNoteDocument }), defineCultNetDocumentBinding({ definition: foreignNoteDocument }),
@ -3473,6 +3485,7 @@ async function withServedBoundRig(
const messages: CultNetMessage[] = []; const messages: CultNetMessage[] = [];
const unparsed: Error[] = []; const unparsed: Error[] = [];
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: registry, documents: registry,
getCache: () => node.cache, getCache: () => node.cache,
bindHost: "127.0.0.1", bindHost: "127.0.0.1",
@ -3634,6 +3647,7 @@ async function withRawPeerRig(
for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put); for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put);
const errors: Error[] = []; const errors: Error[] = [];
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: registry, documents: registry,
getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache, getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache,
bindHost: "127.0.0.1", bindHost: "127.0.0.1",
@ -3708,7 +3722,7 @@ test("CultMesh TS RUDP document server answers a peer whose reliable queue is fu
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5); while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
const refusal = refusalIn(rig.inbox); const refusal = refusalIn(rig.inbox);
assert.ok(refusal, "the refusal reaches the peer"); assert.ok(refusal, "the refusal reaches the peer");
assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot response could not be queued" }); assert.deepEqual(refusal.message, cultNetTextError("the snapshot response could not be queued"));
for (const part of refusal.parts) { for (const part of refusal.parts) {
assert.ok(refused.every((sequence) => !acknowledges(part, sequence)), `ack ${part.ack} mask ${part.ackMask}`); assert.ok(refused.every((sequence) => !acknowledges(part, sequence)), `ack ${part.ack} mask ${part.ackMask}`);
} }
@ -3725,7 +3739,7 @@ test("CultMesh TS RUDP document server refuses a snapshot with fixed text, never
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5); while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
const refusal = refusalIn(rig.inbox); const refusal = refusalIn(rig.inbox);
assert.ok(refusal, "the refusal reaches the peer"); assert.ok(refusal, "the refusal reaches the peer");
assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot source failed" }); assert.deepEqual(refusal.message, cultNetTextError("the snapshot source failed"));
assert.match(rig.errors[0]?.message ?? "", /CANARY-7f3a/); assert.match(rig.errors[0]?.message ?? "", /CANARY-7f3a/);
}); });
}); });

View file

@ -18,8 +18,8 @@
// harness can dial a peer late and exercise Cut C's retained-state seed // harness can dial a peer late and exercise Cut C's retained-state seed
// instead of the provider always tearing down the moment it is done sending. // instead of the provider always tearing down the moment it is done sending.
// //
// Neither mode imports `cultmesh-ts` itself (the harness drives processes, // Neither mode imports `@gamecult/cultmesh-ts` itself (the harness drives processes,
// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the // and the dependency direction is `@gamecult/cultmesh-ts` -> `@gamecult/cultnet-ts`, never the
// reverse); this script reaches the provider/connector through its own // reverse); this script reaches the provider/connector through its own
// package's `src/`. // package's `src/`.

View file

@ -8,8 +8,8 @@ import {
documentPayloadFromBytes, documentPayloadFromBytes,
type DocumentPayload, type DocumentPayload,
} from "@gamecult/cultcache-ts"; } from "@gamecult/cultcache-ts";
import { decodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
import type { CultNetRawDocumentRecord } from "cultnet-ts"; import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts";
import type { CultMeshRudpDocumentPut } from "../src/index"; import type { CultMeshRudpDocumentPut } from "../src/index";
import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire";

View file

@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createSocket } from "node:dgram"; import { createSocket } from "node:dgram";
import { documentPayloadBytes } from "@gamecult/cultcache-ts"; import { documentPayloadBytes } from "@gamecult/cultcache-ts";
import { decodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultNetDocumentRegistry, CultNetDocumentRegistry,
CultNetRudpSession, CultNetRudpSession,
@ -10,10 +10,11 @@ import {
encodeRudpPacket, encodeRudpPacket,
type CultNetOperationRequestMessage, type CultNetOperationRequestMessage,
type CultNetRudpPacket, type CultNetRudpPacket,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import { import {
CultMesh, CultMesh,
CultMeshGrantAllWrites,
CultMeshProviderRudpTransport, CultMeshProviderRudpTransport,
CultMeshProviderSessionBroker, CultMeshProviderSessionBroker,
CULTMESH_PROVIDER_SESSION_SERVICE_ID, CULTMESH_PROVIDER_SESSION_SERVICE_ID,
@ -64,6 +65,7 @@ test("RUDP provider transport requires broker acceptance for the full lifecycle"
}, },
}); });
const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, { const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, {
writeAuthority: new CultMeshGrantAllWrites(),
bindPort: 0, bindPort: 0,
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
onOperationRequest: (request, session) => broker.handle(request, session), onOperationRequest: (request, session) => broker.handle(request, session),
@ -256,6 +258,7 @@ test("a replaced RUDP generation drops operations queued behind an active handle
let handlerCalls = 0; let handlerCalls = 0;
let closedSessions = 0; let closedSessions = 0;
const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, { const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
bindPort: 0, bindPort: 0,
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
onOperationRequest: async request => { onOperationRequest: async request => {
@ -326,6 +329,7 @@ test("a client restarted on the same address with the same Connect payload repla
const connectionId = 0x43554c54; const connectionId = 0x43554c54;
let closedSessions = 0; let closedSessions = 0;
const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, { const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
bindPort: 0, bindPort: 0,
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
onSessionClosed: () => { closedSessions += 1; }, onSessionClosed: () => { closedSessions += 1; },
@ -364,6 +368,7 @@ test("a client restarted on the same address with the same Connect payload repla
test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => { test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => {
const connectionId = 0x43554c55; const connectionId = 0x43554c55;
const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, { const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
bindPort: 0, bindPort: 0,
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
onError: () => undefined, onError: () => undefined,

View file

@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts"; import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts";
import { encodeWireValue } from "cultnet-ts/wire"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
cultMeshProviderSessionSchemas, cultMeshProviderSessionSchemas,

View file

@ -21,8 +21,8 @@ import {
type CultMeshAuthorityRouteView, type CultMeshAuthorityRouteView,
type CultMeshAuthorityTrustPolicy, type CultMeshAuthorityTrustPolicy,
type CultMeshP256PublicKey, type CultMeshP256PublicKey,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
import { import {
CultMeshQuicNativeRuntime, CultMeshQuicNativeRuntime,

View file

@ -14,8 +14,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; import type { CultMeshAuthorityTrustPolicy } from "@gamecult/cultnet-ts";
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
import { import {
CultMeshQuicRealtimeSessionManager, CultMeshQuicRealtimeSessionManager,

View file

@ -0,0 +1,685 @@
// A peer's put or delete is applied only when the server's write authority grants it. Ports the behaviours of
// cultmesh-py test_remote_write_authority.py (no forwarding: this server does not forward non-primary writes).
import assert from "node:assert/strict";
import { createSocket, type Socket } from "node:dgram";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { z } from "zod";
import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts";
import { decodeRudpPacket } from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import {
CultNetDocumentRegistry,
CultNetPeer,
cultNetWriteNotAuthorizedError,
defineCultNetDocumentBinding,
type CultNetMessage,
} from "@gamecult/cultnet-ts";
import {
CultMesh,
CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES,
CultMeshGrantAllWrites,
type CultMeshRemoteWriteAuthority,
type CultMeshRemoteWriteDecision,
type CultMeshRemoteWriteRequest,
type CultMeshRudpDocumentDelete,
type CultMeshRudpDocumentPut,
} from "../src/index";
const SCHEMA_ID = "cultmesh.authority_note.v0";
const ALIAS = "runtime.generated.authority-note.ui.42";
const KEY_A = "tests:authority:a";
const KEY_B = "tests:authority:b";
const note = defineDocumentType({
type: "cultmesh.authority_note",
schemaId: SCHEMA_ID,
compatibleSchemaIds: [ALIAS],
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function put(recordKey: string, extra: Record<string, unknown> = {}, schemaId = SCHEMA_ID, payload?: Uint8Array) {
return {
schemaVersion: "cultnet.document_put_raw.v0" as const,
messageId: `put-${recordKey}`,
document: {
schemaId,
recordKey,
storedAt: "2026-11-07T00:00:00Z",
payloadEncoding: "messagepack" as const,
payload: documentPayloadFromBytes(payload ?? encodeWireValue({ noteId: recordKey, text: "peer" })),
},
...extra,
} as CultNetMessage;
}
function del(recordKey: string, schemaId = SCHEMA_ID): CultNetMessage {
return { schemaVersion: "cultnet.document_delete.v0", messageId: `delete-${recordKey}`, schemaId, recordKey };
}
class Recording implements CultMeshRemoteWriteAuthority {
readonly requests: CultMeshRemoteWriteRequest[] = [];
constructor(private readonly answer: (request: CultMeshRemoteWriteRequest) => unknown) {}
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision> {
this.requests.push(request);
return this.answer(request) as CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
}
}
const grant = () => ({ granted: true });
interface Rig {
puts: CultMeshRudpDocumentPut[];
deletes: CultMeshRudpDocumentDelete[];
errors: Error[];
replies: CultNetMessage[];
peer: CultNetPeer;
settle(): Promise<unknown>;
close(): void;
}
let nextConnection = 0x10204000;
async function rig(writeAuthority?: CultMeshRemoteWriteAuthority, extra: { maxPayloadBytes?: number; writeAuthorityTimeoutMs?: number } = {}): Promise<Rig> {
const connectionId = nextConnection++;
const puts: CultMeshRudpDocumentPut[] = [];
const deletes: CultMeshRudpDocumentDelete[] = [];
const errors: Error[] = [];
const server = CultMesh.createRudpDocumentServer("authority-server", connectionId, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
writeAuthority,
bindHost: "127.0.0.1",
bindPort: 0,
resendDelayMs: 25,
resendPollMs: 5,
onDocumentPutRaw: (document) => { puts.push(document); },
onDocumentDeleteRaw: (request) => { deletes.push(request); },
onError: (error) => errors.push(error),
...extra,
});
await server.start();
const peer: CultNetPeer = await CultMesh.createRudpPeer("authority-peer", connectionId, `rudp://127.0.0.1:${server.bind.port}`, {
resendDelayMs: 25,
resendPollMs: 5,
connectTimeoutMs: 2_000,
});
const replies: CultNetMessage[] = [];
peer.on("message", (message) => replies.push(message));
return {
puts, deletes, errors, replies, peer,
// A refusal is a reply; a grant sends none, so settle on a quiet period after the last send.
settle: () => sleep(250),
close: () => { peer.close(); server.close(); },
};
}
async function using(r: Rig, body: (r: Rig) => Promise<unknown>): Promise<void> {
try { await body(r); } finally { r.close(); }
}
function assertRefused(reply: CultNetMessage | undefined, key: string, error?: string): void {
assert.ok(reply && reply.schemaVersion === "cultnet.error.v0");
assert.equal(reply.code, "write_not_authorized");
assert.ok(reply.error.startsWith("write_not_authorized"));
assert.deepEqual(reply.details, { field: "recordKey", value: key, asOf: null, current: null });
if (error !== undefined) assert.equal(reply.error, error);
}
test("with no authority a peer's put and delete are refused and the handlers never run", async () => {
await using(await rig(undefined), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
r.peer.send(put(KEY_B));
await r.settle();
assert.equal(r.replies.length, 3);
assertRefused(r.replies[0], KEY_A);
assertRefused(r.replies[1], KEY_A);
assertRefused(r.replies[2], KEY_B);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
assert.deepEqual(r.errors, []);
});
});
test("a granted put and delete reach their handlers, send nothing back, and the authority saw this peer", async () => {
const authority = new Recording(grant);
await using(await rig(authority), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
await r.settle();
assert.deepEqual(r.replies, []);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_A]);
assert.equal(authority.requests.length, 2);
for (const request of authority.requests) {
assert.equal(request.peer.address, "127.0.0.1");
assert.ok(request.peer.port > 0);
assert.equal(request.schemaId, SCHEMA_ID, "a granted write was judged under the canonical schema id");
}
assert.deepEqual(authority.requests[1]!.peer, r.deletes[0]!.remote, "the delete handler sees the peer the authority was asked about");
});
});
test("the request carries the resolved write, and scoped grants refuse their neighbours", async () => {
const authority = new Recording((request) =>
request.operation === "put" && request.recordKey === KEY_A ? { granted: true } : { granted: false, reason: "scoped" });
await using(await rig(authority), async (r) => {
r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 }, ALIAS));
await r.settle();
const request = authority.requests[0]!;
assert.equal(request.schemaId, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent");
assert.equal(request.operation, "put");
assert.equal(request.recordKey, KEY_A);
assert.equal(request.shardId, "authority-shard");
assert.equal(request.shardEpoch, 1);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
r.peer.send(put(KEY_B));
r.peer.send(del(KEY_A));
await r.settle();
assertRefused(r.replies[0], KEY_B, "write_not_authorized: scoped");
assertRefused(r.replies[1], KEY_A, "write_not_authorized: scoped");
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "a grant scoped to key a refuses key b");
assert.deepEqual(r.deletes, [], "a grant scoped to put refuses delete");
assert.equal(authority.requests.at(-1)!.operation, "delete");
assert.equal(authority.requests.at(-1)!.schemaId, SCHEMA_ID);
assert.equal(authority.requests.at(-1)!.shardId, null);
});
});
test("a delete sent under a schema alias asks the authority with the canonical id", async () => {
const authority = new Recording(grant);
await using(await rig(authority), async (r) => {
r.peer.send(del(KEY_A, ALIAS));
await r.settle();
assert.equal(authority.requests[0]!.schemaId, SCHEMA_ID);
assert.equal(r.deletes.length, 1);
});
});
test("refusal precedes every other judgment of the put", async () => {
await using(await rig(undefined, { maxPayloadBytes: 256 }), async (r) => {
r.peer.send(put("unrelated:authority", {}, "sha256:nobody-owns-this"));
r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)));
await r.settle();
assertRefused(r.replies[0], "unrelated:authority");
assertRefused(r.replies[1], KEY_A);
assert.deepEqual(r.errors, [], "the gate ran before the put was sized or resolved");
assert.equal(r.puts.length, 0);
});
const granting = new Recording(grant);
await using(await rig(granting, { maxPayloadBytes: 256 }), async (r) => {
r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)));
await r.settle();
assert.equal(r.replies[0]?.schemaVersion, "cultnet.error.v0");
assert.notEqual((r.replies[0] as { code?: string }).code, "write_not_authorized", "a granted put still meets the size judgment");
assert.equal(r.errors.length, 1);
});
});
test("an authority that throws or rejects refuses, fails closed, and is reported without being quoted", async () => {
for (const decide of [
() => { throw new Error("authority exploded"); },
() => Promise.reject(new Error("authority exploded")),
]) {
await using(await rig(new Recording(decide)), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
await r.settle();
assertRefused(r.replies[0], KEY_A);
assertRefused(r.replies[1], KEY_A);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
assert.equal(r.errors.length, 2);
assert.ok(r.errors.every((e) => e.message.includes("Error") && !e.message.includes("exploded")));
});
}
});
test("an authority that never settles refuses after the timeout and does not block the frames behind it", async () => {
// The first put hangs forever; later frames from the same session must still be judged.
const authority = new Recording((request) => request.recordKey === KEY_A ? new Promise(() => {}) : { granted: true });
await using(await rig(authority, { writeAuthorityTimeoutMs: 100 }), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(put(KEY_B));
await sleep(600);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_B], "the granted put behind the hung one was applied");
assert.equal(r.replies.length, 1);
assertRefused(r.replies[0], KEY_A);
assert.equal((r.replies[0] as { error: string }).error.includes("timed out"), true);
assert.equal(r.errors.length, 1);
assert.ok(r.errors[0]!.message.includes("did not answer within 100 ms"));
});
});
test("an authority that settles GRANTED after the timeout is still refused and the write never reaches the handler", async () => {
const authority = new Recording((request) => request.recordKey === KEY_A
? new Promise((resolve) => setTimeout(() => resolve({ granted: true }), 300))
: { granted: true });
await using(await rig(authority, { writeAuthorityTimeoutMs: 100 }), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
await sleep(700);
assert.equal(r.replies.length, 2, "one refusal per frame, no late second reply");
assertRefused(r.replies[0], KEY_A);
assertRefused(r.replies[1], KEY_A);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], "the late grant never reached the handler");
});
});
test("a write authority timeout that is not a usable timer delay is refused at construction", () => {
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 2 ** 31]) {
assert.throws(() => CultMesh.createRudpDocumentServer("authority-server", 1, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
bindHost: "127.0.0.1",
bindPort: 0,
writeAuthorityTimeoutMs: bad,
}), RangeError, String(bad));
}
});
test("a refusal with no reason still says why, and a missing decision refuses", async () => {
await using(await rig(new Recording(() => ({ granted: false }))), async (r) => {
r.peer.send(put(KEY_A));
await r.settle();
assertRefused(r.replies[0], KEY_A, "write_not_authorized");
});
for (const answer of [null, undefined, {}, { granted: "yes" }, { granted: 1 }]) {
await using(await rig(new Recording(() => answer)), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
await r.settle();
assertRefused(r.replies[0], KEY_A);
assertRefused(r.replies[1], KEY_A);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
});
}
});
test("the refusal on the wire is the pinned error-vectors bytes", async () => {
const vectors = JSON.parse(readFileSync(join(__dirname, "../../../../contracts/cultnet/interop/error-vectors.json"), "utf8")).vectors;
const vector = vectors.find((v: { name: string }) => v.name === "write_not_authorized");
const expected = Buffer.from(vector.messagePackHex, "hex");
assert.deepEqual(Buffer.from(encodeWireValue(cultNetWriteNotAuthorizedError("some:key", "not granted"))), expected);
await using(await rig(new Recording(() => ({ granted: false, reason: "not granted" }))), async (r) => {
r.peer.send(put("some:key"));
await r.settle();
assert.deepEqual(Buffer.from(encodeWireValue(r.replies[0])), expected);
});
});
test("wrong-typed shard fields on a put are judged with null shard fields, then refused as malformed once granted", async () => {
const authority = new Recording(grant);
await using(await rig(authority), async (r) => {
const bad: Record<string, unknown>[] = [
{ shardId: { not: "a string" } }, { shardId: 5 }, { shardEpoch: "7" }, { shardEpoch: true }, { shardEpoch: 1.5 },
];
for (const fields of bad) r.peer.send(put(KEY_A, fields));
await r.settle();
assert.equal(r.replies.length, bad.length);
assert.deepEqual(
r.replies.map((reply) => reply.schemaVersion === "cultnet.error.v0" ? [reply.code, reply.details?.field] : reply),
["shardId", "shardId", "shardEpoch", "shardEpoch", "shardEpoch"].map((field) => ["message_invalid", field]),
);
assert.equal(authority.requests.length, bad.length, "the authority is asked first");
assert.ok(authority.requests.every((q) => q.shardId === null && q.shardEpoch === null), "with null shard fields");
assert.equal(r.puts.length, 0);
});
await using(await rig(undefined), async (r) => {
r.peer.send(put(KEY_A, { shardId: 5 }));
await r.settle();
assertRefused(r.replies[0], KEY_A);
});
});
test("a refusal fits maxPayloadBytes however long the record key and reason are", async () => {
const longKey = "k".repeat(3_000) + "é";
await using(await rig(new Recording(() => ({ granted: false, reason: "r".repeat(100_000) })), { maxPayloadBytes: 1024 }), async (r) => {
r.peer.send(put(longKey));
r.peer.send(del(longKey));
await r.settle();
assert.equal(r.replies.length, 2);
for (const reply of r.replies) {
assert.equal((reply as { code?: string }).code, "write_not_authorized");
assert.ok(encodeWireValue(reply).byteLength <= 1024);
assert.ok(longKey.startsWith((reply as { details: { value: string } }).details.value));
}
assert.deepEqual(r.errors, []);
});
});
test("well-typed shard fields reach the authority as sent", async () => {
const authority = new Recording(grant);
await using(await rig(authority), async (r) => {
r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 }));
await r.settle();
assert.deepEqual([authority.requests[0]!.shardId, authority.requests[0]!.shardEpoch], ["authority-shard", 1]);
});
});
test("grant-all is an explicit choice that grants every write", async () => {
await using(await rig(new CultMeshGrantAllWrites()), async (r) => {
r.peer.send(put(KEY_A));
r.peer.send(del(KEY_A));
await r.settle();
assert.deepEqual([r.puts.length, r.deletes.length, r.replies.length], [1, 1, 0]);
});
});
test("a granted delete with no delete handler is answered with a text refusal", async () => {
const connectionId = nextConnection++;
const server = CultMesh.createRudpDocumentServer("no-delete", connectionId, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
writeAuthority: new CultMeshGrantAllWrites(),
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
});
await server.start();
const peer = await CultMesh.createRudpPeer("p", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5, connectTimeoutMs: 2_000 });
const replies: CultNetMessage[] = [];
peer.on("message", (m) => replies.push(m));
try {
peer.send(del(KEY_A));
await sleep(250);
assert.equal(replies.length, 1);
assert.notEqual((replies[0] as { code?: string }).code, "write_not_authorized");
} finally { peer.close(); server.close(); }
});
// Hostile frames, sent as raw bytes the typed peer would never build.
const bin = (n: number, ...tail: number[]) => [0xc6, (n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255, ...tail]; // bin32 header
const str = (t: string) => [0xa0 | t.length, ...Buffer.from(t)];
function rawPut(payloadBytes: number[], key = KEY_A): Uint8Array {
// {schemaVersion, messageId, document: {schemaId, recordKey, storedAt, payloadEncoding, payload: <payloadBytes>}}
return Uint8Array.from([
0x83, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("messageId"), ...str("raw"),
...str("document"), 0x85, ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), ...str(key),
...str("storedAt"), ...str("2026-11-07T00:00:00Z"), ...str("payloadEncoding"), ...str("messagepack"),
...str("payload"), ...payloadBytes,
]);
}
async function rawRig(writeAuthority?: CultMeshRemoteWriteAuthority) {
const connectionId = nextConnection++;
const puts: CultMeshRudpDocumentPut[] = [];
const deletes: CultMeshRudpDocumentDelete[] = [];
const errors: Error[] = [];
const server = CultMesh.createRudpDocumentServer("authority-raw", connectionId, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
writeAuthority, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
onDocumentPutRaw: (d) => { puts.push(d); }, onDocumentDeleteRaw: (d) => { deletes.push(d); }, onError: (e) => errors.push(e),
});
await server.start();
const client = await CultMesh.createRudpClient("raw", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5 });
client.connect();
const started = Date.now();
while (!client.connected) {
if (Date.now() - started > 2_000) throw new Error("not connected");
await sleep(5);
}
const replies: CultNetMessage[] = [];
new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m));
return { puts, deletes, errors, replies, send: (b: Uint8Array) => client.send("schema", b), close: () => { client.close(); server.close(); } };
}
const float64 = (v: number) => { const b = Buffer.alloc(9); b[0] = 0xcb; b.writeDoubleBE(v, 1); return [...b]; };
// Each hostile frame, the operation it is judged as, and the ids the authority is told (empty when the frame does not carry them).
const hostile: { name: string; frame: Uint8Array; operation: "put" | "delete"; schemaId: string; recordKey: string }[] = [
{ name: "an int payload", frame: rawPut([0x2a]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
{ name: "a uint64-max integer payload", frame: rawPut([0xcf, 255, 255, 255, 255, 255, 255, 255, 255]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
{ name: "a float64 2**70 payload", frame: rawPut(float64(2 ** 70)), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
{ name: "a bin whose claimed length exceeds the frame", frame: rawPut(bin(1_000_000_000, 1, 2, 3)), operation: "put", schemaId: "", recordKey: "" },
{ name: "a frame that does not decode at all", frame: Uint8Array.from([0xc1]), operation: "put", schemaId: "", recordKey: "" },
{ name: "a put whose document is not a map", frame: Uint8Array.from([0x82, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("document"), 0x05]), operation: "put", schemaId: "", recordKey: "" },
{ name: "a delete whose recordKey is not a string", frame: Uint8Array.from([0x84, ...str("schemaVersion"), ...str("cultnet.document_delete.v0"), ...str("messageId"), ...str("d"), ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), 0x07]), operation: "delete", schemaId: SCHEMA_ID, recordKey: "" },
];
/** Records the size of every buffer a frame makes the process allocate, so a transient allocation is seen after it is freed. */
function watchAllocations(): { largest: () => number; stop: () => void } {
let largest = 0;
const note = (n: unknown) => { if (typeof n === "number" && n > largest) largest = n; };
const originals = { alloc: Buffer.alloc, unsafe: Buffer.allocUnsafe, slow: Buffer.allocUnsafeSlow, u8: globalThis.Uint8Array, ab: globalThis.ArrayBuffer };
Buffer.alloc = ((n: number, ...rest: never[]) => { note(n); return originals.alloc.call(Buffer, n, ...rest); }) as typeof Buffer.alloc;
Buffer.allocUnsafe = ((n: number) => { note(n); return originals.unsafe.call(Buffer, n); }) as typeof Buffer.allocUnsafe;
Buffer.allocUnsafeSlow = ((n: number) => { note(n); return originals.slow.call(Buffer, n); }) as typeof Buffer.allocUnsafeSlow;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const watched = (ctor: any): any => new Proxy(ctor, { construct: (target, args, newTarget) => { note(args[0]); return Reflect.construct(target, args, newTarget); } });
(globalThis as { Uint8Array: unknown }).Uint8Array = watched(originals.u8);
(globalThis as { ArrayBuffer: unknown }).ArrayBuffer = watched(originals.ab);
return {
largest: () => largest,
stop: () => {
Buffer.alloc = originals.alloc; Buffer.allocUnsafe = originals.unsafe; Buffer.allocUnsafeSlow = originals.slow;
(globalThis as { Uint8Array: unknown }).Uint8Array = originals.u8;
(globalThis as { ArrayBuffer: unknown }).ArrayBuffer = originals.ab;
},
};
}
test("an ungranted peer's unreadable write is refused with write_not_authorized naming what it carried, and nothing is allocated for it", async () => {
for (const h of hostile) {
const authority = new Recording(() => ({ granted: false }));
for (const withAuthority of [false, true]) {
const r = await rawRig(withAuthority ? authority : undefined);
const watch = watchAllocations();
try {
r.send(h.frame);
await sleep(250);
} finally { watch.stop(); }
try {
assert.equal(r.replies.length, 1, h.name);
assertRefused(r.replies[0], h.recordKey);
assert.ok(watch.largest() < 10_000_000, `${h.name}: allocated ${watch.largest()} bytes`);
assert.deepEqual([r.puts.length, r.deletes.length, r.errors.length], [0, 0, 0], h.name);
} finally { r.close(); }
}
assert.equal(authority.requests.length, 1, h.name);
assert.deepEqual(
[authority.requests[0]!.operation, authority.requests[0]!.schemaId, authority.requests[0]!.recordKey],
[h.operation, h.schemaId, h.recordKey], h.name);
}
});
test("the allocation watch sees a transient allocation", () => {
const watch = watchAllocations();
try { void Buffer.alloc(20_000_000); } finally { watch.stop(); }
assert.ok(watch.largest() >= 20_000_000);
const typed = watchAllocations();
try { void new Uint8Array(20_000_000); } finally { typed.stop(); }
assert.ok(typed.largest() >= 20_000_000);
});
test("a granted peer's unreadable write reaches onError and gets a malformed refusal, and no handler runs", async () => {
for (const h of hostile) {
const authority = new Recording(grant);
const r = await rawRig(authority);
try {
r.send(h.frame);
await sleep(250);
assert.equal(r.replies.length, 1, h.name);
const reply = r.replies[0] as { code: unknown; error: string; details: { field: string } };
assert.equal(reply.code, "message_invalid", h.name);
assert.equal(reply.details.field, "message", h.name);
assert.equal(r.errors.length, 1, h.name);
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], h.name);
assert.deepEqual([authority.requests.length, authority.requests[0]!.operation, authority.requests[0]!.recordKey], [1, h.operation, h.recordKey], h.name);
} finally { r.close(); }
}
});
const CAP = 8; // the cap every runtime shares; the exported constant must stay this number
test("the consecutive-refused-frames cap is the number every runtime shares", () => {
assert.equal(CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES, CAP);
});
test("unreadable frames are answered and the session kept until eight in a row; any decoded frame resets the run", async () => {
const r = await rawRig(new Recording(grant));
try {
const batch = async (frames: number) => {
for (let i = 0; i < frames; i++) r.send(Uint8Array.from([0xc1]));
await sleep(400);
};
await batch(CAP - 1);
assert.equal(r.replies.length, CAP - 1, "each is answered");
r.send(encodeWireValue(put(KEY_A)));
await sleep(250);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
await batch(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1), "the valid frame reset the run");
r.send(Uint8Array.from([0xc1]));
await sleep(300);
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth consecutive one is answered too");
assert.ok(r.replies.every((reply) => (reply as { code?: string }).code === "message_invalid"));
// The goodbye reached the peer: its client no longer takes a send.
assert.throws(() => r.send(encodeWireValue(put(KEY_B))), /before the session is connected/, "then the session ended");
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
} finally { r.close(); }
});
test("deletes the authority refuses are answered and the session kept until eight in a row; any other decoded frame resets the run", async () => {
const authority = new Recording((request) => request.recordKey === KEY_B ? { granted: true } : { granted: false });
await using(await rig(authority), async (r) => {
const refuse = async (deletes: number) => {
for (let i = 0; i < deletes; i++) r.peer.send(del(KEY_A));
await sleep(400);
};
await refuse(CAP - 1);
assert.equal(r.replies.length, CAP - 1);
r.peer.send(del(KEY_B)); // granted: resets the run
await sleep(250);
assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_B]);
await refuse(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1));
r.peer.send(put(KEY_B)); // a decoded non-delete resets too
await sleep(250);
await refuse(CAP - 1);
assert.equal(r.replies.length, 3 * (CAP - 1), "a put reset the run");
r.peer.send(del(KEY_A));
await sleep(300);
assert.equal(r.replies.length, 3 * (CAP - 1) + 1, "the eighth consecutive refused delete is answered too");
for (const reply of r.replies) assertRefused(reply, KEY_A);
assert.throws(() => r.peer.send(del(KEY_B)), /before the session is connected/, "then the session ended");
assert.equal(r.deletes.length, 1);
});
});
test("a frame that decodes but is no message the parser takes is answered, counted, and ended at eight in a row like any unreadable frame", async () => {
const r = await rawRig(new Recording(grant));
const unknown = () => encodeWireValue({ schemaVersion: "cultnet.not-a-message.v0", messageId: "x" });
try {
const batch = async (frames: number) => {
for (let i = 0; i < frames; i++) r.send(unknown());
await sleep(400);
};
await batch(CAP - 1);
assert.equal(r.replies.length, CAP - 1, "each is answered");
assert.equal(r.errors.length, CAP - 1, "and reported");
r.send(encodeWireValue(put(KEY_A)));
await sleep(250);
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
await batch(CAP - 1);
assert.equal(r.replies.length, 2 * (CAP - 1), "the decoded put reset the run");
r.send(unknown());
await sleep(300);
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth is answered too");
for (const reply of r.replies) {
assert.equal((reply as { code?: string }).code, "message_invalid");
assert.equal((reply as { details: { field: string } }).details.field, "message");
}
assert.throws(() => r.send(unknown()), /before the session is connected/, "then the session ended");
} finally { r.close(); }
});
/** A UDP relay between a client and a server that drops the first (or, with `dropEvery`, every) server datagram carrying `payloadContains` once armed. */
async function lossyRelay(serverPort: number, payloadContains: string, dropEvery = false) {
const dropped: unknown[] = [];
let armed = false;
let clientAddress: { port: number; address: string } | undefined;
const toClient: Socket = createSocket("udp4");
const toServer: Socket = createSocket("udp4");
toServer.on("message", (wire) => {
if (!clientAddress) return;
if (armed && (dropEvery || dropped.length === 0)) {
const packet = decodeRudpPacket(wire);
if (packet.packetType === "data" && Buffer.from(packet.payload ?? []).includes(payloadContains)) {
dropped.push(packet);
return;
}
}
toClient.send(wire, clientAddress.port, clientAddress.address);
});
toClient.on("message", (wire, remote) => {
clientAddress = remote;
toServer.send(wire, serverPort, "127.0.0.1");
});
await new Promise<void>((resolve) => toClient.bind(0, "127.0.0.1", resolve));
await new Promise<void>((resolve) => toServer.bind(0, "127.0.0.1", resolve));
return {
port: toClient.address().port,
dropped,
arm: () => { armed = true; },
close: () => { toClient.close(); toServer.close(); },
};
}
async function cappedOverRelay(dropEvery: boolean, body: (x: { relay: Awaited<ReturnType<typeof lossyRelay>>; client: { send(channel: string, payload: Uint8Array): void }; replies: CultNetMessage[] }) => Promise<void>) {
const connectionId = nextConnection++;
const server = CultMesh.createRudpDocumentServer("authority-lossy", connectionId, {
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
writeAuthority: new Recording(grant), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
onDocumentPutRaw: () => {}, onError: () => {},
});
await server.start();
const relay = await lossyRelay(server.bind.port, "message_invalid", dropEvery);
const client = await CultMesh.createRudpClient("lossy", connectionId, `rudp://127.0.0.1:${relay.port}`, { resendDelayMs: 25, resendPollMs: 5 });
try {
client.connect();
const started = Date.now();
while (!client.connected) {
if (Date.now() - started > 2_000) throw new Error("not connected");
await sleep(5);
}
const replies: CultNetMessage[] = [];
new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m));
for (let i = 0; i < CAP - 1; i++) client.send("schema", Uint8Array.from([0xc1]));
await sleep(300);
assert.equal(replies.length, CAP - 1);
relay.arm();
client.send("schema", Uint8Array.from([0xc1]));
await body({ relay, client, replies });
} finally { client.close(); server.close(); relay.close(); }
}
test("the capped frame's answer is resent when its datagram is lost, and the goodbye follows only after it arrived", async () => {
await cappedOverRelay(false, async ({ relay, client, replies }) => {
await sleep(600);
assert.equal(relay.dropped.length, 1, "fixture: the first transmission of the last answer was lost");
assert.equal(replies.length, CAP, "the lost answer was resent and arrived");
assert.equal((replies[CAP - 1] as { code?: string }).code, "message_invalid");
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "then the session ended");
});
});
test("a capped session whose answer is never acknowledged still ends, after the grace", async () => {
await cappedOverRelay(true, async ({ client, replies }) => {
await sleep(1200);
assert.equal(replies.length, CAP - 1, "fixture: the last answer never arrives");
client.send("schema", Uint8Array.from([0xc1])); // still connected: the server is waiting for the acknowledgement
await sleep(1500);
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "the grace ran out and the goodbye went out");
});
});
test("nothing behind the capped frame is served while its answer is delivered", async () => {
const r = await rawRig(new Recording(grant));
try {
for (let i = 0; i < CAP; i++) r.send(Uint8Array.from([0xc1]));
r.send(encodeWireValue(put(KEY_A)));
await sleep(500);
assert.equal(r.replies.length, CAP);
assert.deepEqual(r.puts, [], "the put behind the cap was not served");
} finally { r.close(); }
});

View file

@ -6,15 +6,15 @@ import assert from "node:assert/strict";
import { createSocket, type RemoteInfo, type Socket } from "node:dgram"; import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
import test from "node:test"; import test from "node:test";
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { encodeWireValue } from "cultnet-ts/wire"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultNetDocumentRegistry, CultNetDocumentRegistry,
CultNetRudpSession, CultNetRudpSession,
decodeRudpPacket, decodeRudpPacket,
encodeRudpPacket, encodeRudpPacket,
type CultNetPeer, type CultNetPeer,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import { CultMesh } from "../src/index"; import { CultMesh, CultMeshGrantAllWrites } from "../src/index";
async function waitFor(predicate: () => boolean, description: string): Promise<void> { async function waitFor(predicate: () => boolean, description: string): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
@ -64,6 +64,7 @@ async function startServer(connectionId: number, socket: Socket): Promise<Server
const closed: number[] = []; const closed: number[] = [];
const errors: Error[] = []; const errors: Error[] = [];
const server = CultMesh.createRudpDocumentServer("send-failure-server", connectionId, { const server = CultMesh.createRudpDocumentServer("send-failure-server", connectionId, {
writeAuthority: new CultMeshGrantAllWrites(),
documents: new CultNetDocumentRegistry(), documents: new CultNetDocumentRegistry(),
socket, socket,
bindHost: "127.0.0.1", bindHost: "127.0.0.1",

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

@ -2,6 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor) ## Unreleased (breaking; version 0.x, so the next minor)
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per - Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
`tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C# `tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C#

View file

@ -8,7 +8,7 @@ wire contracts that both a node and a bare peer have to agree on.
It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret` It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret`
light up with the optional `crypto` extra. `cultmesh-py` builds on it. The light up with the optional `crypto` extra. `cultmesh-py` builds on it. The
layering mirrors `cultnet-rs` and `cultnet-ts`. layering mirrors `cultnet-rs` and `@gamecult/cultnet-ts`.
## Example ## Example

View file

@ -49,6 +49,7 @@ from .messages import (
document_put_raw, document_put_raw,
encode_witness_artifact_bundle_payload, encode_witness_artifact_bundle_payload,
error_message, error_message,
message_invalid_error,
unsupported_schema_version_error, unsupported_schema_version_error,
hello, hello,
login, login,
@ -262,6 +263,7 @@ __all__ = [
"encode_frame", "encode_frame",
"encode_rudp_packet", "encode_rudp_packet",
"error_message", "error_message",
"message_invalid_error",
"unsupported_schema_version_error", "unsupported_schema_version_error",
"hello", "hello",
"INTEROP_WIRE_CONTRACT", "INTEROP_WIRE_CONTRACT",

View file

@ -58,6 +58,7 @@ class CultNetRawClient:
create_transport: CultNetSchemaTransportFactory | None = None create_transport: CultNetSchemaTransportFactory | None = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None: def send(self, message: CultNetMessage | dict[str, Any]) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
wire = message.to_wire() if isinstance(message, CultNetMessage) else message wire = message.to_wire() if isinstance(message, CultNetMessage) else message
with self.open_transport() as transport: with self.open_transport() as transport:
transport.send("schema", msgpack.packb(wire, use_bin_type=True)) transport.send("schema", msgpack.packb(wire, use_bin_type=True))
@ -319,6 +320,7 @@ class CultNetDatabaseSubscription:
self._transport = None self._transport = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None: def send(self, message: CultNetMessage | dict[str, Any]) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
if self._transport is None: if self._transport is None:
raise RuntimeError("CultNet database subscription is not open") raise RuntimeError("CultNet database subscription is not open")
wire = message.to_wire() if isinstance(message, CultNetMessage) else message wire = message.to_wire() if isinstance(message, CultNetMessage) else message

View file

@ -44,6 +44,7 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
"unowned_schema", "unowned_schema",
"write_not_authorized", "write_not_authorized",
"unsupported_schema_version", "unsupported_schema_version",
"message_invalid",
) )
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current") _ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
@ -185,6 +186,11 @@ def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage:
) )
def message_invalid_error(field: str, reason: str) -> CultNetMessage:
"""The refusal for a request that lacks a required field or carries an ill-typed one (C# ForMessageInvalid)."""
return error_message(f"message_invalid: {reason}", code="message_invalid", details={"field": field})
def login_success( def login_success(
*, *,
nonce: str, nonce: str,

View file

@ -22,6 +22,7 @@ CULTNET_ERROR_CODES = (
"unowned_schema", "unowned_schema",
"write_not_authorized", "write_not_authorized",
"unsupported_schema_version", "unsupported_schema_version",
"message_invalid",
) )
WIRE_MESSAGE_SCHEMA_VERSIONS = ( WIRE_MESSAGE_SCHEMA_VERSIONS = (

View file

@ -1256,6 +1256,7 @@ class CultNetRudpSocketTransportConnection:
self._send_packet(self.session.create_connect(_now_ms(), payload)) self._send_packet(self.session.create_connect(_now_ms(), payload))
def send(self, channel_id: str, payload: bytes) -> None: def send(self, channel_id: str, payload: bytes) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
packets = self.session.send_many( packets = self.session.send_many(
channel_id, channel_id,
payload, payload,
@ -1567,6 +1568,7 @@ class TcpFramedTransportConnection:
) )
def send(self, channel_id: str, payload: bytes) -> None: def send(self, channel_id: str, payload: bytes) -> None:
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
if channel_id != "schema": if channel_id != "schema":
raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}") raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}")
before = len(payload) + 4 before = len(payload) + 4

View file

@ -6,7 +6,7 @@ from pathlib import Path
import msgpack import msgpack
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error from cultnet_py import CULTNET_ERROR_CODES, error_message, message_invalid_error, parse_message, unsupported_schema_version_error
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned # contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes). # by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
@ -51,6 +51,10 @@ class CultNetErrorMessageTests(unittest.TestCase):
) )
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"]) self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"]) self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"])
self.assertEqual(
message_invalid_error("document", "Raw put messages must contain a document map.").to_bytes(),
by_name["message_invalid"],
)
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None: def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
wire = error_message("boom").to_wire() wire = error_message("boom").to_wire()

View file

@ -8,6 +8,7 @@ only in git history.
### Added ### Added
- `CultNetErrorCode::MessageInvalid` (`message_invalid`), the code for a request that lacks a required field or carries an ill-typed one; a captured-C#-bytes case pins it.
- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`. - The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`.
- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`. - The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`.
- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane. - Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane.

View file

@ -1,6 +1,6 @@
# CultNet RS # CultNet RS
`cultnet-rs` is the Rust sibling of `cultnet-ts`: typed MessagePack messages, `cultnet-rs` is the Rust sibling of `@gamecult/cultnet-ts`: typed MessagePack messages,
4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and 4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and
CultCache replication without making callers paw through raw envelopes like we CultCache replication without making callers paw through raw envelopes like we
lost a bet. lost a bet.
@ -100,7 +100,7 @@ game-session authority remain separate responsibilities.
## Local Fast Lane ## Local Fast Lane
`cultnet-rs` now mirrors the raw replication seam from `cultnet-ts`: `cultnet-rs` now mirrors the raw replication seam from `@gamecult/cultnet-ts`:
- `cultnet.document_put_raw.v0` - `cultnet.document_put_raw.v0`
- `cultnet.snapshot_response_raw.v0` - `cultnet.snapshot_response_raw.v0`

View file

@ -28,7 +28,7 @@
"code": { "code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).", "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"], "type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"] "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
}, },
"details": { "details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,7 +36,7 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["field", "value", "asOf", "current"], "required": ["field", "value", "asOf", "current"],
"properties": { "properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }

View file

@ -248,6 +248,10 @@ pub enum CultNetErrorCode {
UnownedSchema, UnownedSchema,
WriteNotAuthorized, WriteNotAuthorized,
UnsupportedSchemaVersion, UnsupportedSchemaVersion,
MessageInvalid,
/// `cultnet.error.v1` only: the session's remembered-write limit is full. `cultnet.error.v0` neither
/// encodes nor decodes it, so its enum stays the schema's.
LimitReached,
} }
impl CultNetErrorCode { impl CultNetErrorCode {
@ -261,6 +265,8 @@ impl CultNetErrorCode {
Self::UnownedSchema => "unowned_schema", Self::UnownedSchema => "unowned_schema",
Self::WriteNotAuthorized => "write_not_authorized", Self::WriteNotAuthorized => "write_not_authorized",
Self::UnsupportedSchemaVersion => "unsupported_schema_version", Self::UnsupportedSchemaVersion => "unsupported_schema_version",
Self::MessageInvalid => "message_invalid",
Self::LimitReached => "limit_reached",
} }
} }
@ -274,6 +280,8 @@ impl CultNetErrorCode {
"unowned_schema" => Ok(Self::UnownedSchema), "unowned_schema" => Ok(Self::UnownedSchema),
"write_not_authorized" => Ok(Self::WriteNotAuthorized), "write_not_authorized" => Ok(Self::WriteNotAuthorized),
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
"message_invalid" => Ok(Self::MessageInvalid),
"limit_reached" => Ok(Self::LimitReached),
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
} }
} }
@ -356,6 +364,26 @@ pub enum CultNetMessage {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>, details: Option<CultNetErrorDetails>,
}, },
/// `cultnet.error.v1` (contracts/cultnet/interop/write-receipt.md): `cultnet.error.v0` plus the id of the
/// request it answers. Sent only to a connection that declared the write-receipt capability.
#[serde(skip_deserializing)]
#[serde(rename = "cultnet.error.v1", rename_all = "camelCase")]
ErrorV1 {
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<CultNetErrorCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>,
message_id: Option<String>,
},
/// `cultnet.write_admitted.v0`: the receipt for one admitted put or delete, sent after its commit point.
#[serde(rename = "cultnet.write_admitted.v0", rename_all = "camelCase")]
WriteAdmitted {
message_id: String,
operation: String,
schema_id: String,
record_key: String,
},
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
SampleChangeName { name: String }, SampleChangeName { name: String },
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
@ -540,7 +568,9 @@ pub fn encode_cultnet_message_for_wire(
| CultNetMessage::SnapshotResponseRawV1 { .. } | CultNetMessage::SnapshotResponseRawV1 { .. }
| CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkRequest { .. }
| CultNetMessage::ContentChunkResponse { .. } | CultNetMessage::ContentChunkResponse { .. }
| CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), | CultNetMessage::Error { .. }
| CultNetMessage::ErrorV1 { .. }
| CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message),
_ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec( _ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec(
&serde_json::to_value(message)?, &serde_json::to_value(message)?,
)?)?), )?)?),
@ -570,7 +600,8 @@ pub fn decode_cultnet_message_from_slice(
| "cultnet.snapshot_response_raw.v0" | "cultnet.snapshot_response_raw.v0"
| "cultmesh.content_chunk_request.v1" | "cultmesh.content_chunk_request.v1"
| "cultmesh.content_chunk_response.v1" | "cultmesh.content_chunk_response.v1"
| "cultnet.error.v0", | "cultnet.error.v0"
| "cultnet.error.v1",
) => parse_raw_cultnet_schema_message(&wire_value) ) => parse_raw_cultnet_schema_message(&wire_value)
.with_context(|| received_version_label(&wire_value))?, .with_context(|| received_version_label(&wire_value))?,
_ => cultcache_rs::decode_payload::<CultNetMessage>(bytes) _ => cultcache_rs::decode_payload::<CultNetMessage>(bytes)
@ -642,6 +673,20 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
require_non_empty(session, "session")?; require_non_empty(session, "session")?;
} }
CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?, CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?,
CultNetMessage::ErrorV1 { error, message_id, .. } => {
require_non_empty(error, "error")?;
if let Some(message_id) = message_id {
require_non_empty(message_id, "messageId")?;
}
}
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => {
require_non_empty(message_id, "messageId")?;
if operation != "put" && operation != "delete" {
return Err(anyhow!("operation must be put or delete"));
}
require_non_empty(schema_id, "schemaId")?;
require_non_empty(record_key, "recordKey")?;
}
CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?,
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
CultNetMessage::DocumentDelete { CultNetMessage::DocumentDelete {
@ -1018,7 +1063,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
.to_vec(), .to_vec(),
error: require_legacy_string(get("error"), "error")?, error: require_legacy_string(get("error"), "error")?,
}), }),
"cultnet.error.v0" => { "cultnet.error.v0" | "cultnet.error.v1" => {
let code = get("code") let code = get("code")
.filter(|value| !value.is_nil()) .filter(|value| !value.is_nil())
.map(|value| { .map(|value| {
@ -1028,12 +1073,23 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
.and_then(CultNetErrorCode::from_wire_str) .and_then(CultNetErrorCode::from_wire_str)
}) })
.transpose()?; .transpose()?;
if schema_version == "cultnet.error.v0" && code == Some(CultNetErrorCode::LimitReached) {
return Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code"));
}
let details = require_error_details(get("details"), "ErrorMessage.Details")?; let details = require_error_details(get("details"), "ErrorMessage.Details")?;
Ok(CultNetMessage::Error { let error = require_legacy_string(get("error"), "ErrorMessage.Error")?;
error: require_legacy_string(get("error"), "ErrorMessage.Error")?, if schema_version == "cultnet.error.v1" {
code, return Ok(CultNetMessage::ErrorV1 {
details, error,
}) code,
details,
message_id: match get("messageId") {
None | Some(rmpv::Value::Nil) => None,
other => Some(require_legacy_string(other, "messageId")?),
},
});
}
Ok(CultNetMessage::Error { error, code, details })
} }
_ => Err(anyhow!( _ => Err(anyhow!(
"Unsupported raw cultnet.schema.v0 message" "Unsupported raw cultnet.schema.v0 message"
@ -1087,7 +1143,18 @@ fn require_legacy_optional_u64(
} }
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> { fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
if let CultNetMessage::Error { code: Some(CultNetErrorCode::LimitReached), .. } = message {
return Err(anyhow!("limit_reached is a cultnet.error.v1 code; cultnet.error.v0 cannot carry it"));
}
Ok(rmpv::Value::Map(match message { Ok(rmpv::Value::Map(match message {
// Spec field order (write-receipt.md), not serde_json's sorted map.
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => vec![
(rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.write_admitted.v0")),
(rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())),
(rmpv::Value::from("operation"), rmpv::Value::from(operation.as_str())),
(rmpv::Value::from("schemaId"), rmpv::Value::from(schema_id.as_str())),
(rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())),
],
CultNetMessage::DocumentPutRaw { CultNetMessage::DocumentPutRaw {
message_id, message_id,
document, document,
@ -1252,6 +1319,36 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
.unwrap_or(rmpv::Value::Nil), .unwrap_or(rmpv::Value::Nil),
), ),
], ],
CultNetMessage::ErrorV1 {
error,
code,
details,
message_id,
} => vec![
(rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.error.v1")),
(rmpv::Value::from("error"), rmpv::Value::from(error.as_str())),
(rmpv::Value::from("routingHint"), rmpv::Value::Nil),
(
rmpv::Value::from("code"),
code.as_ref()
.map(|code| rmpv::Value::from(code.as_wire_str()))
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("details"),
details
.as_ref()
.map(encode_error_details)
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("messageId"),
message_id
.as_deref()
.map(rmpv::Value::from)
.unwrap_or(rmpv::Value::Nil),
),
],
_ => { _ => {
return Err(anyhow!( return Err(anyhow!(
"Message is not a raw cultnet.schema.v0 binary replication message" "Message is not a raw cultnet.schema.v0 binary replication message"
@ -1881,3 +1978,71 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result<rmpv::Va
), ),
])) ]))
} }
/// The content hash a write-receipt server binds to a `(actor, messageId)` dedupe key
/// (contracts/cultnet/interop/write-receipt.md, rule 5): SHA-256 of the MessagePack array
/// `[operation, schemaId, recordKey, payload]`, `payload` a `bin` for a put and nil for a delete.
pub fn write_content_hash(
operation: &str,
schema_id: &str,
record_key: &str,
payload: Option<&[u8]>,
) -> Result<[u8; 32]> {
use sha2::{Digest, Sha256};
let value = rmpv::Value::Array(vec![
rmpv::Value::from(operation),
rmpv::Value::from(schema_id),
rmpv::Value::from(record_key),
payload.map_or(rmpv::Value::Nil, |payload| rmpv::Value::Binary(payload.to_vec())),
]);
let mut bytes = Vec::new();
rmpv::encode::write_value(&mut bytes, &value)?;
Ok(Sha256::digest(&bytes).into())
}
/// SHA-256 of a write's `messageId`: what a receipt server remembers in place of the id itself, so an
/// entry's memory does not depend on the id's length (write-receipt.md, "What is remembered").
pub fn message_id_digest(message_id: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
Sha256::digest(message_id.as_bytes()).into()
}
/// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal
/// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not
/// a MessagePack map or hold no non-empty string `messageId`.
pub fn readable_message_id(bytes: &[u8]) -> Option<String> {
let value = rmpv::decode::read_value(&mut &*bytes).ok()?;
let rmpv::Value::Map(entries) = value else {
return None;
};
entries.into_iter().find_map(|(key, value)| match (key.as_str(), value) {
(Some("messageId"), rmpv::Value::String(id)) => id.into_str().filter(|id| !id.is_empty()),
_ => None,
})
}
/// The first required field (`document`, then `schemaId`) that a put or delete frame lacks or holds as nil
/// (write-receipt.md, "Required fields"), with the write's operation (`"put"` or `"delete"`). `None` when the
/// bytes are not such a frame, or both fields are present. A put's `schemaId` is its document's.
pub fn missing_required_write_field(bytes: &[u8]) -> Option<(&'static str, &'static str)> {
let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &*bytes).ok()? else {
return None;
};
let field = |entries: &[(rmpv::Value, rmpv::Value)], name: &str| {
entries
.iter()
.find(|(key, _)| key.as_str() == Some(name))
.map(|(_, value)| value.clone())
.filter(|value| !value.is_nil())
};
match field(&entries, "schemaVersion")?.as_str()? {
"cultnet.document_put_raw.v0" => match field(&entries, "document") {
Some(rmpv::Value::Map(document)) => {
field(&document, "schemaId").is_none().then_some(("put", "schemaId"))
}
_ => Some(("put", "document")),
},
"cultnet.document_delete.v0" => field(&entries, "schemaId").is_none().then_some(("delete", "schemaId")),
_ => None,
}
}

View file

@ -468,6 +468,14 @@ impl CultNetRudpSession {
self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec()) self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec())
} }
/// Ends a session because the server itself could not answer a write it owes an answer to (its answer
/// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the
/// "session refused a packet" of a protocol refusal.
pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket {
self.reset_peer_state();
self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec())
}
/// Ends a session that owes its peer a packet that can never be sent as /// Ends a session that owes its peer a packet that can never be sent as
/// built (see `is_permanent_send_error`). Resending it would fail forever, /// built (see `is_permanent_send_error`). Resending it would fail forever,
/// and dropping it would leave a reliable sequence the peer waits on for /// and dropping it would leave a reliable sequence the peer waits on for
@ -2198,6 +2206,7 @@ impl CultNetRudpSocketTransportConnection {
self.send_packet(&packet) self.send_packet(&packet)
} }
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> { pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> {
let options = let options =
channel_send_options( channel_send_options(
@ -2216,6 +2225,8 @@ impl CultNetRudpSocketTransportConnection {
/// Sends one frame on a reliable channel whose packets cannot expire and /// Sends one frame on a reliable channel whose packets cannot expire and
/// returns the transport receipt used to observe its exact acknowledgement. /// returns the transport receipt used to observe its exact acknowledgement.
///
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send_reliable( pub fn send_reliable(
&mut self, &mut self,
channel_id: &str, channel_id: &str,
@ -2309,6 +2320,9 @@ impl CultNetRudpSocketTransportConnection {
self.session.outstanding_reliable_packet_count() self.session.outstanding_reliable_packet_count()
} }
/// Waits until every reliable write is acknowledged by the transport.
///
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> { pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> {
let original_timeout = self.socket.read_timeout()?; let original_timeout = self.socket.read_timeout()?;
let poll_timeout = original_timeout let poll_timeout = original_timeout
@ -2603,6 +2617,8 @@ where
/// Sent to a peer whose session refused one of its packets, and reported to /// Sent to a peer whose session refused one of its packets, and reported to
/// the local caller as the disconnect reason. /// the local caller as the disconnect reason.
const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet";
/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`).
const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write";
const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out";
/// Prefix of the goodbye reason for a session that owed a packet which can /// Prefix of the goodbye reason for a session that owed a packet which can
/// never be sent; the failure's fixed name (`send_error_code`) follows it. /// never be sent; the failure's fixed name (`send_error_code`) follows it.

View file

@ -252,6 +252,7 @@ impl<TStream> TcpFramedTransportConnection<TStream>
where where
TStream: Write, TStream: Write,
{ {
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> { pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> {
if channel_id != "schema" { if channel_id != "schema" {
return Err(anyhow!( return Err(anyhow!(

View file

@ -188,6 +188,21 @@ fn cases() -> Vec<Case> {
}), }),
}, },
}, },
Case {
name: "message_invalid",
// C#: ForMessageInvalid("document", "Raw put messages must contain a document map.")
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "message_invalid: Raw put messages must contain a document map.".to_string(),
code: Some(CultNetErrorCode::MessageInvalid),
details: Some(CultNetErrorDetails {
field: Some("document".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
] ]
} }

View file

@ -0,0 +1,155 @@
//! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames.
use cultnet_rs::{
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice,
encode_cultnet_message_to_vec, write_content_hash,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn the_content_hash_matches_the_spec_vectors() {
let a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31];
let b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32];
let cases = [
(write_content_hash("put", "note.v0", "note:1", Some(&a)), "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d"),
(write_content_hash("put", "note.v0", "note:1", Some(&b)), "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f"),
(write_content_hash("put", "note.v0", "note:2", Some(&a)), "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8"),
(write_content_hash("delete", "note.v0", "note:1", None), "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37"),
];
for (hash, expected) in cases {
assert_eq!(hex(&hash.unwrap()), expected);
}
}
#[test]
fn the_new_frames_decode_back_equal() {
let admitted = CultNetMessage::WriteAdmitted {
message_id: "put-1".into(),
operation: "put".into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
};
let bytes = encode_cultnet_message_to_vec(&admitted, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(),
admitted
);
// error.v1 with a nil id round-trips as nil, and the code enum is v0's.
let error = CultNetMessage::ErrorV1 {
error: "message_invalid: unreadable frame".into(),
code: Some(CultNetErrorCode::MessageInvalid),
details: None,
message_id: None,
};
let bytes = encode_cultnet_message_to_vec(&error, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(),
error
);
}
fn unhex(text: &str) -> Vec<u8> {
(0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect()
}
/// The spec's wire vectors (write-receipt-vectors.json `wire`): the bytes decode to the vector's fields, not
/// merely to something that encodes back to itself.
#[test]
fn the_spec_wire_vectors_decode_to_their_expected_fields() {
let admitted = decode_cultnet_message_from_slice(
&unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
assert_eq!(
admitted,
CultNetMessage::WriteAdmitted {
message_id: "put-1".into(),
operation: "put".into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
}
);
let refused = decode_cultnet_message_from_slice(
&unhex("86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32"),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
let CultNetMessage::ErrorV1 { error, code, details, message_id } = refused else {
panic!("expected error.v1");
};
assert_eq!(error, "write_not_authorized: the catalog refused the document");
assert_eq!(code, Some(CultNetErrorCode::WriteNotAuthorized));
assert_eq!(message_id.as_deref(), Some("put-2"));
let details = details.unwrap();
assert_eq!(details.field.as_deref(), Some("recordKey"));
assert_eq!(details.value.as_deref(), Some("note:2"));
assert_eq!((details.as_of, details.current), (None, None));
}
/// Spec vector `error_v1_limit_reached`: the bytes the server must send, and what they decode to.
#[test]
fn error_v1_limit_reached_is_the_spec_vector() {
let vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235";
let message = CultNetMessage::ErrorV1 {
error: "limit_reached: the session's remembered-write limit is full".into(),
code: Some(CultNetErrorCode::LimitReached),
details: Some(CultNetErrorDetails::default()),
message_id: Some("put-1025".into()),
};
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(bytes, unhex(vector), "encoded bytes must equal the spec vector");
let CultNetMessage::ErrorV1 { error, code, details, message_id } =
decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap()
else {
panic!("expected error.v1");
};
assert_eq!(error, "limit_reached: the session's remembered-write limit is full");
assert_eq!(code, Some(CultNetErrorCode::LimitReached));
assert_eq!(message_id.as_deref(), Some("put-1025"));
assert_eq!(details, Some(CultNetErrorDetails::default()));
}
/// Spec vectors `write_admitted_put` and `write_admitted_delete`: the encoder emits the spec's field order.
#[test]
fn write_admitted_encodes_to_the_spec_vector_bytes() {
let cases = [
("put-1", "put", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
("del-1", "delete", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"),
];
for (id, operation, vector) in cases {
let message = CultNetMessage::WriteAdmitted {
message_id: id.into(),
operation: operation.into(),
schema_id: "note.v0".into(),
record_key: "note:1".into(),
};
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap();
assert_eq!(bytes, unhex(vector));
}
}
/// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it.
#[test]
fn error_v0_refuses_the_v1_only_limit_reached_code() {
let v0 = CultNetMessage::Error {
error: "x".into(),
code: Some(CultNetErrorCode::LimitReached),
details: None,
};
assert!(encode_cultnet_message_to_vec(&v0, CultNetWireContract::CultNetSchemaV0).is_err());
let v1 = CultNetMessage::ErrorV1 {
error: "x".into(),
code: Some(CultNetErrorCode::LimitReached),
details: None,
message_id: None,
};
let v1_bytes = encode_cultnet_message_to_vec(&v1, CultNetWireContract::CultNetSchemaV0).unwrap();
let at = v1_bytes.windows(16).position(|w| w == b"cultnet.error.v1").unwrap();
let mut as_v0 = v1_bytes.clone();
as_v0[at + 15] = b'0';
assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err());
}

View file

@ -1,18 +1,24 @@
# Changelog # Changelog
All notable changes to `cultnet-ts` are recorded here, following All notable changes to `@gamecult/cultnet-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## Unreleased
### Added ### 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.
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer. - `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported. - The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported.
### Breaking ### 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. - `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.
- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference. - `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference.

View file

@ -1,6 +1,6 @@
# CultNet TS # CultNet TS
`cultnet-ts` is the TypeScript port of the useful shape of GameCult's `@gamecult/cultnet-ts` is the TypeScript port of the useful shape of GameCult's
networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style
security/session helpers, and state replication that hands typed values into a security/session helpers, and state replication that hands typed values into a
`CultCache` instead of making consumer code paw raw database envelopes. `CultCache` instead of making consumer code paw raw database envelopes.
@ -42,7 +42,7 @@ We want:
So the split is: So the split is:
- `cultcache-ts`: typed local persistence - `cultcache-ts`: typed local persistence
- `cultnet-ts`: typed transport, auth, and state replication - `@gamecult/cultnet-ts`: typed transport, auth, and state replication
Same family. Different organs. Nobody needs a second spine growing out of the Same family. Different organs. Nobody needs a second spine growing out of the
transport layer. transport layer.
@ -80,7 +80,7 @@ CultNet without inventing a second ontology first.
## Generated Swarm Contracts ## Generated Swarm Contracts
`cultnet-ts` can consume canonical JSON Schema files and generate: `@gamecult/cultnet-ts` can consume canonical JSON Schema files and generate:
- TypeScript contract classes/interfaces - TypeScript contract classes/interfaces
- runtime schema verifiers - runtime schema verifiers
@ -111,7 +111,7 @@ which exports:
## Wire Contracts ## Wire Contracts
`cultnet-ts` does not sniff random payloads and pretend that counts as rigor. `@gamecult/cultnet-ts` does not sniff random payloads and pretend that counts as rigor.
You choose the contract explicitly. You choose the contract explicitly.
Current wire-contract options: Current wire-contract options:
@ -129,7 +129,7 @@ away from the C# lineage.
## Content ## Content
`cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN `@gamecult/cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN
(`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk (`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk
reference, their MessagePack form, hash normalization, both record-key grammars, reference, their MessagePack form, hash normalization, both record-key grammars,
SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it
@ -160,7 +160,7 @@ imports no `node:` module, so a browser bundle can take it.
## Local Fast Lane ## Local Fast Lane
For direct-pipe neighbors that already share the same CultCache formatter For direct-pipe neighbors that already share the same CultCache formatter
contract, `cultnet-ts` now has a raw replication lane: contract, `@gamecult/cultnet-ts` now has a raw replication lane:
- `cultnet.document_put_raw.v0` - `cultnet.document_put_raw.v0`
- `cultnet.snapshot_response_raw.v0` - `cultnet.snapshot_response_raw.v0`
@ -185,7 +185,7 @@ cuts out the dumbest part of the trip.
Apps sharing a CultNet space should be able to ask each other what they can Apps sharing a CultNet space should be able to ask each other what they can
exchange without resorting to folklore. exchange without resorting to folklore.
`cultnet-ts` now includes: `@gamecult/cultnet-ts` now includes:
- `cultnet.schema_catalog_request.v0` - `cultnet.schema_catalog_request.v0`
- `cultnet.schema_catalog_response.v0` - `cultnet.schema_catalog_response.v0`
@ -232,7 +232,7 @@ import {
createTcpFramedCultNetPeer, createTcpFramedCultNetPeer,
defineCultNetDocumentBinding, defineCultNetDocumentBinding,
ghostlightAgentStateGeneratedContract, ghostlightAgentStateGeneratedContract,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
const ghostlightStateDefinition = defineDocumentType({ const ghostlightStateDefinition = defineDocumentType({
type: "ghostlight.agent-state", type: "ghostlight.agent-state",
@ -270,7 +270,7 @@ That means a TypeScript runtime can follow the same cryptographic and session
story as the C# version instead of inventing one more house religion around story as the C# version instead of inventing one more house religion around
"trust me, this pipe is probably fine." "trust me, this pipe is probably fine."
For the legacy `gamecult.networking.v0` contract, `cultnet-ts` normalizes the For the legacy `gamecult.networking.v0` contract, `@gamecult/cultnet-ts` normalizes the
encrypted binary fields as base64url strings in the ergonomic TS surface and encrypted binary fields as base64url strings in the ergonomic TS surface and
maps them back to the exact C# union payload shape at the wire boundary. maps them back to the exact C# union payload shape at the wire boundary.
@ -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 serve and dial commands also exercise schema-v0 over the shared RUDP socket
transport and advertise both transport profiles. 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 ## Development
```bash ```bash

View file

@ -1,5 +1,5 @@
{ {
"name": "cultnet-ts", "name": "@gamecult/cultnet-ts",
"version": "0.3.0", "version": "0.3.0",
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
"main": "dist/index.js", "main": "dist/index.js",
@ -73,7 +73,7 @@
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"@gamecult/cultcache-ts": "^0.14.1" "@gamecult/cultcache-ts": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View file

@ -1,3 +1,4 @@
import { isNullOrWhiteSpaceCSharp } from "./cultmesh-authority";
import type { DocumentPayload } from "@gamecult/cultcache-ts/document"; import type { DocumentPayload } from "@gamecult/cultcache-ts/document";
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js"; import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
@ -8,6 +9,8 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json";
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json"; import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json"; import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
import errorSchema from "../contracts/cultnet/cultnet.error.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 sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json"; import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json"; import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
@ -61,6 +64,8 @@ export type CultNetSchemaVersion =
| "cultnet.verify.v0" | "cultnet.verify.v0"
| "cultnet.login_success.v0" | "cultnet.login_success.v0"
| "cultnet.error.v0" | "cultnet.error.v0"
| "cultnet.error.v1"
| "cultnet.write_admitted.v0"
| "cultnet.sample.change_name.v0" | "cultnet.sample.change_name.v0"
| "cultnet.sample.chat.v0" | "cultnet.sample.chat.v0"
| "cultnet.document_put.v0" | "cultnet.document_put.v0"
@ -195,10 +200,49 @@ export const CULTNET_ERROR_CODES = [
"unowned_schema", "unowned_schema",
"write_not_authorized", "write_not_authorized",
"unsupported_schema_version", "unsupported_schema_version",
"message_invalid",
] as const; ] as const;
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; 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 };
}
/**
* The refusal for a peer write its server's authority did not grant (C# `CultNetErrorMessage.ForRemoteWriteRefused`):
* code `write_not_authorized`, `details` naming the record in `recordKey`. A blank `reason` leaves the text bare.
*/
export function cultNetWriteNotAuthorizedError(recordKey: string, reason?: string | null): CultNetErrorMessage {
return {
schemaVersion: "cultnet.error.v0",
error: typeof reason === "string" && !isNullOrWhiteSpaceCSharp(reason) ? `write_not_authorized: ${reason}` : "write_not_authorized",
routingHint: null,
code: "write_not_authorized",
details: { field: "recordKey", value: recordKey, asOf: null, current: null },
};
}
/**
* The refusal for a request that lacks a required field or carries an ill-typed one (C# `CultNetErrorMessage.ForMessageInvalid`):
* code `message_invalid`, `details.field` naming that request field.
*/
export function cultNetMessageInvalidError(field: string, reason: string): CultNetErrorMessage {
return {
schemaVersion: "cultnet.error.v0",
error: `message_invalid: ${reason}`,
routingHint: null,
code: "message_invalid",
details: { field, value: null, asOf: null, current: null },
};
}
export interface CultNetErrorRoutingHint { export interface CultNetErrorRoutingHint {
shard: Record<string, unknown> | null; shard: Record<string, unknown> | null;
reason: string; reason: string;
@ -220,6 +264,34 @@ export interface CultNetErrorMessage {
details: CultNetErrorDetails | null; 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 { export interface CultNetSampleChangeNameMessage {
schemaVersion: "cultnet.sample.change_name.v0"; schemaVersion: "cultnet.sample.change_name.v0";
name: string; name: string;
@ -735,6 +807,8 @@ export interface GhostlightAgentStateDocument {
export type CultNetMessage = export type CultNetMessage =
| CultNetHelloMessage | CultNetHelloMessage
| CultNetErrorV1Message
| CultNetWriteAdmittedMessage
| CultNetLoginMessage | CultNetLoginMessage
| CultNetRegisterMessage | CultNetRegisterMessage
| CultNetVerifyMessage | CultNetVerifyMessage
@ -777,6 +851,8 @@ const CULTNET_MESSAGE_SCHEMAS = [
verifySchema, verifySchema,
loginSuccessSchema, loginSuccessSchema,
errorSchema, errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema, sampleChangeNameSchema,
sampleChatSchema, sampleChatSchema,
documentPutSchema, documentPutSchema,
@ -958,14 +1034,8 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"), session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"),
}; };
case 4: case 4:
return { // The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
schemaVersion: "cultnet.error.v0", return cultNetTextError(requireLegacyString(payload[0], "ErrorMessage.Error"));
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
routingHint: null,
code: null,
details: null,
};
case 5: case 5:
return { return {
schemaVersion: "cultnet.sample.change_name.v0", schemaVersion: "cultnet.sample.change_name.v0",
@ -1506,6 +1576,8 @@ export const cultNetSchemas = {
verifySchema, verifySchema,
loginSuccessSchema, loginSuccessSchema,
errorSchema, errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema, sampleChangeNameSchema,
sampleChatSchema, sampleChatSchema,
documentPutSchema, documentPutSchema,

View file

@ -39,8 +39,8 @@ export interface CultMeshAuthorityRouteCertificate {
/** /**
* Maps an advertised route's wire-shaped certificate to the view * Maps an advertised route's wire-shaped certificate to the view
* `verifyAuthorityRoute` and the transports consume. Shared by the QUIC * `verifyAuthorityRoute` and the transports consume. Shared by the QUIC
* realtime session manager (`cultmesh-ts`) and the browser WebSocket client * realtime session manager (`@gamecult/cultmesh-ts`) and the browser WebSocket client
* (`cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` * (`@gamecult/cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage`
* certificate shape and were computing this mapping independently. * certificate shape and were computing this mapping independently.
*/ */
export function routeCertificateView( export function routeCertificateView(

View file

@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher(
const publish = async (detail: string) => { const publish = async (detail: string) => {
const signed = signer.sign(detail); const signed = signer.sign(detail);
messageSequence += 1; messageSequence += 1;
await publishDocument(endpoint, connectionId, { await sendPresenceDocument(endpoint, connectionId, {
schemaVersion: "cultnet.document_put_raw.v0", schemaVersion: "cultnet.document_put_raw.v0",
messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`,
document: signed.document, 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 }, endpoint: { host: string; port: number },
connectionId: number, connectionId: number,
message: Record<string, unknown>, message: Record<string, unknown>,
): Promise<void> { ): Promise<"sent"> {
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
socket.once("error", reject); socket.once("error", reject);
@ -107,26 +115,34 @@ async function publishDocument(
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
for (const packet of packets) await sendPacket(socket, endpoint, packet); for (const packet of packets) await sendPacket(socket, endpoint, packet);
await receiveUntil( // The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything.
receiver, const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
session, if (frame.channelId !== "schema") return;
endpoint, const response = parseCultNetMessage(decode(frame.payload));
() => session.outstandingReliablePacketCount === 0, if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
2000, // An error.v1 refuses this put only when it names it (or names nothing).
(frame) => { if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) {
if (frame.channelId !== "schema") return; throw new IdunnPresenceRefusedError(response);
const response = parseCultNetMessage(decode(frame.payload)); }
if (response.schemaVersion === "cultnet.error.v0") { };
throw new Error(`Odin rejected runtime presence: ${response.error}`); await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame);
} return "sent";
},
);
} finally { } finally {
receiver.close(); receiver.close();
socket.close(); socket.close();
} }
} }
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
export class IdunnPresenceRefusedError extends Error {
readonly code: string | undefined;
constructor(readonly response: { error: string; code?: string | null }) {
super(`Odin rejected runtime presence: ${response.error}`);
this.name = "IdunnPresenceRefusedError";
this.code = response.code ?? undefined;
}
}
type PacketReceiver = { type PacketReceiver = {
socket: dgram.Socket; socket: dgram.Socket;
next(timeoutMs: number): Promise<CultNetRudpPacket>; next(timeoutMs: number): Promise<CultNetRudpPacket>;

View file

@ -17,6 +17,15 @@ export {
type CultNetRawDocumentRecord, type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding, type CultNetRawPayloadEncoding,
CULTNET_ERROR_CODES, CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
helloAdvertisesWriteReceipts,
type CultNetErrorV1Code,
type CultNetErrorV1Message,
type CultNetWriteAdmittedMessage,
cultNetTextError,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
type CultNetErrorCode, type CultNetErrorCode,
type CultNetErrorDetails, type CultNetErrorDetails,
type CultNetErrorMessage, type CultNetErrorMessage,
@ -246,3 +255,15 @@ export {
type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisher,
type IdunnRuntimePresencePublisherOptions, type IdunnRuntimePresencePublisherOptions,
} from "./idunn-odin-presence-publisher"; } 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

@ -95,6 +95,10 @@ export class CultNetPeer extends EventEmitter {
return this.#transport?.profile; return this.#transport?.profile;
} }
/**
* Fire-and-forget: an ack or a return is not admission; read the result frame (a write is refused with
* `cultnet.error.v0`, `write_not_authorized` or `message_invalid`, as a `message` event).
*/
send(message: CultNetMessage): void { send(message: CultNetMessage): void {
const wireValue = encodeCultNetMessageForWire(message, this.#wireContract); const wireValue = encodeCultNetMessageForWire(message, this.#wireContract);
const payload = encode(wireValue); const payload = encode(wireValue);
@ -106,6 +110,7 @@ export class CultNetPeer extends EventEmitter {
this.#stream?.write(encodeFrame(payload)); this.#stream?.write(encodeFrame(payload));
} }
/** Resolves when the transport has acknowledged what was sent. An ack is not admission; read the result frame. */
async flush(timeoutMs?: number): Promise<void> { async flush(timeoutMs?: number): Promise<void> {
await this.#transport?.flush?.(timeoutMs); await this.#transport?.flush?.(timeoutMs);
} }

View file

@ -270,6 +270,20 @@ export class CultNetDocumentRegistry {
); );
} }
/**
* The canonical schema id a put or delete naming `schemaId` writes under, from the id alone, or `undefined` when no
* binding takes it (or several claim it). A server asks its write authority about this id, not the alias a peer sent.
*/
canonicalSchemaId(schemaId: string): string | undefined {
try {
const binding = this.getBySchemaId(schemaId);
return binding ? schemaIdForBinding(binding) : undefined;
} catch (error) {
if (error instanceof SchemaConflictError) return undefined;
throw error;
}
}
async applyDocumentDeleteMessage( async applyDocumentDeleteMessage(
cache: CultCache, cache: CultCache,
message: CultNetDocumentDeleteMessage, message: CultNetDocumentDeleteMessage,

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

@ -8,6 +8,8 @@ import { decode, encode } from "@msgpack/msgpack";
import { import {
CULTNET_ERROR_CODES, CULTNET_ERROR_CODES,
cultNetSchemas, cultNetSchemas,
cultNetMessageInvalidError,
cultNetWriteNotAuthorizedError,
encodeCultNetMessageForWire, encodeCultNetMessageForWire,
parseCultNetMessage, parseCultNetMessage,
type CultNetErrorMessage, type CultNetErrorMessage,
@ -81,3 +83,25 @@ test("a code outside the closed enum is refused, as is a malformed details objec
/cultnet\.error\.v0/, /cultnet\.error\.v0/,
); );
}); });
test("the write_not_authorized factory writes the C# vector's bytes and names the key", () => {
const vector = vectors.find((v) => v.name === "write_not_authorized")!;
assert.equal(hexOf(wireOf(cultNetWriteNotAuthorizedError("some:key", "not granted"))), vector.messagePackHex);
assert.deepEqual(cultNetWriteNotAuthorizedError("some:key", "not granted"), vector.message);
});
test("the message_invalid factory writes the C# vector's bytes and names the field", () => {
const vector = vectors.find((v) => v.name === "message_invalid")!;
const built = cultNetMessageInvalidError("document", "Raw put messages must contain a document map.");
assert.equal(hexOf(wireOf(built)), vector.messagePackHex);
assert.deepEqual(built, vector.message);
});
test("a write_not_authorized refusal with no reason, or a blank one, says only the code", () => {
assert.equal(cultNetWriteNotAuthorizedError("k").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", " ").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", null).error, "write_not_authorized");
// C# string.IsNullOrWhiteSpace counts U+0085 as white space and not U+FEFF; JS trim() disagrees on both.
assert.equal(cultNetWriteNotAuthorizedError("k", "\u0085").error, "write_not_authorized");
assert.equal(cultNetWriteNotAuthorizedError("k", "\ufeff").error, "write_not_authorized: \ufeff");
});

View file

@ -3078,3 +3078,15 @@ test("document put message omits unset optional keys so the encoded wire has no
assert.equal(stamped.document.sourceRuntimeId, "r"); assert.equal(stamped.document.sourceRuntimeId, "r");
assert.deepEqual(stamped.document.tags, ["t"]); assert.deepEqual(stamped.document.tags, ["t"]);
}); });
test("CultNet registry names the canonical schema id of a compatible alias, and none for an unknown or conflicted id", () => {
const shape = z.object({ body: z.string() });
const owner = defineDocumentType({ type: "p3.owner", schemaId: "p3.owner.v2", compatibleSchemaIds: ["p3.owner.v1"], schema: shape });
const first = defineDocumentType({ type: "p3.first", schemaId: "p3.first", compatibleSchemaIds: ["p3.shared"], schema: shape });
const second = defineDocumentType({ type: "p3.second", schemaId: "p3.second", compatibleSchemaIds: ["p3.shared"], schema: shape });
const registry = new CultNetDocumentRegistry([owner, first, second].map(definition => defineCultNetDocumentBinding({ definition })));
assert.equal(registry.canonicalSchemaId("p3.owner.v1"), "p3.owner.v2");
assert.equal(registry.canonicalSchemaId("p3.owner.v2"), "p3.owner.v2");
assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined);
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none");
});

View file

@ -6,7 +6,7 @@ import test from "node:test";
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it // The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show // over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
// the region between the `snippet` markers verbatim, except that the package import reads // the region between the `snippet` markers verbatim, except that the package import reads
// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ. // "@gamecult/cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
// snippet:begin // snippet:begin
import { createServer, connect } from "node:net"; import { createServer, connect } from "node:net";
import { z } from "zod"; import { z } from "zod";
@ -72,7 +72,7 @@ test("docs show the tested snippet", async () => {
const snippet = source const snippet = source
.split("// snippet:begin\n")[1] .split("// snippet:begin\n")[1]
.split("// snippet:end\n")[0] .split("// snippet:end\n")[0]
.replace('"../src"', '"cultnet-ts"'); .replace('"../src"', '"@gamecult/cultnet-ts"');
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
}); });

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(); }
});

Some files were not shown because too many files have changed in this diff Show more