Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
aff69c2914 Merge pull request 'cultmesh-ts test: queued provider registers observed from creation (RUDP replaced-generation flake)' (#70) from libby/rudp-queue-test-unhandled-rejection 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 6m51s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 16s
2026-11-09 19:32:05 +00:00
88545542bb Merge pull request 'write_admitted messageId: letter or number, no Default_Ignorable, raw compare' (#69) from libby/write-admitted-messageid-rule 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 6m48s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-09 16:49:35 +00:00
soul
2aade7fdd3 Docs and test comments: correct the Rust duplicate-key citation, say what the TS and Rust duplicate tests pin
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 5m24s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 16:25:50 +00:00
soul
27d9fbca94 write-receipt.md: where a received messageId decides, and the single-parser assumption it relies on
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-09 16:07:11 +00:00
soul
e08d5c47f4 GameCult.Networking: a write_admitted frame with more than one messageId key is malformed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 16:05:21 +00:00
soul
cc5a9cec9b tests first: repeated messageId key, each runtime's declared policy (C# failing)
Vectors admittedMessageIdDuplicates carry [invalid,valid], [valid,invalid], [valid,valid]. Decoders that see the
duplicate (C#, Rust) must refuse all three; map-collapsing decoders (Python, TS, Kotlin) judge the last key.
Python, TS, Kotlin and Rust already behave as declared; C# fails: it accepted [valid,valid].

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 16:04:58 +00:00
soul
b55521d50d cultmesh-ts test: observe queued provider registers from creation so a client timeout is not an unhandled rejection
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 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:50:10 +00:00
soul
a97861945c write-receipt.md: the messageId rule is on the decoded string; byte-level UTF-8 strictness is the reader's, not required
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 32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Failing after 30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:13:09 +00:00
soul
6c130581a1 cultmesh-kotlin: UTF-8 strictness scoped to the write_admitted frame, not the shared MessagePack reader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:13:01 +00:00
soul
99273f468c cultnet-ts: refuse a write_admitted messageId that is not well-formed Unicode (lone surrogate)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:12:07 +00:00
soul
2f372eb2f1 GameCult.Networking: check every messageId key of a write_admitted frame, not only the first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:11:53 +00:00
soul
f378d4fdb0 tests first: duplicate messageId, ill-formed id, scoped UTF-8 strictness, every table range pinned (failing)
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
Failing before the fix: C# 1 (duplicate messageId, second is U+200B), TypeScript 1 (a + lone surrogate),
Kotlin 1 (a document payload string with a lone surrogate no longer decodes). Python and Rust already pass
the new vectors. The range probes read the generated write-admitted-message-id-ranges.json and pass on the
correct tables; they exist to fail when any range is deleted or narrowed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:11:15 +00:00
soul
8598fd3d12 cultnet-rs: write_admitted messageId from the generated Unicode 16.0 tables (letters and numbers now 16.0, not 15.1)
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-09 15:05:00 +00:00
soul
c2d2c1d126 GameCult.Networking: write_admitted messageId from embedded Unicode 16.0 tables, not .NET's
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:05:00 +00:00
soul
c4f6e76839 cultmesh-kotlin: write_admitted messageId from embedded Unicode 16.0 tables, not the JDK's
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:04:59 +00:00
soul
87f53498ed cultnet-py: write_admitted messageId from embedded Unicode 16.0 tables, not unicodedata
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:04:59 +00:00
soul
de25b50abd cultnet-ts: write_admitted messageId from embedded Unicode 16.0 tables, not the regex engine's
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:04:59 +00:00
soul
3d37fda496 write_admitted messageId: pin Unicode 16.0, one generator for the embedded tables
scripts/gen-unicode-messageid-tables.py emits each runtime's Letter-or-Number and Default_Ignorable tables from the two UCD 16.0 files; --check fails on a stale or hand-edited table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:04:59 +00:00
soul
cc931932fc tests first: Unicode 16.0 pin vectors (failing)
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 6m15s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 43s
Failing before the fix: Rust 1 (16100), Python 2 (16100, 10D40), Kotlin 4 (16100, 10D40, 2EBF0, 2EE5D).
TypeScript and C# already pass: their platform data is 16.0 or later.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:00:54 +00:00
soul
ba548d192d cultnet-rs: refuse a write_admitted messageId without a letter or number or with a Default_Ignorable code point
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 compare. std is_numeric for N; embedded letter (Unicode 15.1) and Default_Ignorable (16.0) range tables.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:49:50 +00:00
soul
d546ef9c7f GameCult.Networking: refuse a write_admitted messageId without a letter or number, with a Default_Ignorable code point, or with invalid UTF-8
Raw compare. .NET Unicode categories for L/N, an embedded Default_Ignorable range table (Unicode 16.0).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:49:50 +00:00
soul
d1a4ebdd68 cultmesh-kotlin: refuse a write_admitted messageId without a letter or number or with a Default_Ignorable code point
Raw compare. JDK Character categories for L/N, an embedded Default_Ignorable range table (Unicode 16.0).
The MessagePack string reader now refuses invalid UTF-8 instead of substituting U+FFFD.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:45:37 +00:00
soul
688562c316 cultnet-py: refuse a write_admitted messageId without a letter or number or with a Default_Ignorable code point
Raw compare. unicodedata for L/N, an embedded Default_Ignorable range table (Unicode 16.0).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:43:26 +00:00
soul
bf55c10199 vector: write_admitted messageId refuses U+FEFF before a letter (kills a trim-before-check decoder)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:42:44 +00:00
soul
4c1537885d cultnet-ts: refuse a write_admitted messageId without a letter or number or with a Default_Ignorable code point
Raw compare, no trim. The platform's regex property tables carry both properties; no table embedded.
D800 vector now driven from wireHex.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:42:01 +00:00
soul
70f8135d2c vector: write_admitted messageId accepts a NUL between letters (rule: letter present, no Default_Ignorable)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:41:11 +00:00
soul
4571a0d4e7 tests first: write_admitted messageId rule vectors (failing)
Pins Heimdall's rule: a write_admitted messageId needs a Unicode letter or number and no
Default_Ignorable_Code_Point, compared raw. Shared vectors plus a runner test in TS, Python,
Kotlin, C# and Rust. No decoder changed; the refuse vectors fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:39:51 +00:00
325da81428 Merge pull request 'TS write-receipt client against the real Rust and C# receipt servers (interop harness)' (#67) from libby/write-receipt-ts-interop 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 59s
2026-11-09 14:16:00 +00:00
e4f550ef78 Merge pull request 'cultnet-py: decode write_admitted.v0 and error.v1 against the write-receipt vectors' (#66) from libby/write-receipt-py-interop 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-09 14:15:59 +00:00
59365cea35 Merge pull request 'cultmesh-kotlin: write receipt frames, hash, tracker, vector replay' (#65) from libby/write-receipt-kt-interop 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-09 14:15:58 +00:00
5ba3e9e2aa Merge pull request 'Rust RUDP server: pin blank schemaId/recordKey refusal, decide sequenced refusal per frame' (#63) from libby/rs-receipt-key-tests-per-frame 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-09 14:15:49 +00:00
69703358c0 Merge pull request 'write-receipt: one unreadable text, one blank-field rule (both servers)' (#64) from libby/write-receipt-text-blank-rules 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-09 14:15:48 +00:00
soul
96c5fb1ca4 interop peer: C# store holds the spec's admitted schema-id names (compatible ids)
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 6m32s
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-09 14:12:38 +00:00
soul
b8e40dc372 interop test: pace C# relay connections under the server's per-IP per-minute limit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:10:42 +00:00
soul
1704fb5060 cultmesh-ts: test:interop builds cultcache-ts and cultnet-ts (with contracts) first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:05:57 +00:00
Libby
8c31933e10 cultnet-py: the write-receipt vector sections include the file's counts
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 7m35s
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-09 10:38:29 +00:00
Libby
2f3b30ab85 Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/rs-receipt-key-tests-per-frame
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m0s
2026-11-09 10:37:49 +00:00
Libby
819df6a666 Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/write-receipt-ts-interop
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 10m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 45s
2026-11-09 10:37:49 +00:00
Libby
e490828278 Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/write-receipt-py-interop 2026-11-09 10:37:49 +00:00
Libby
6ca9c455b7 cultmesh-kotlin: sendFailed vectors, a tracker that ends with its session, White_Space blank
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 6m53s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m12s
The replay runner handles the sendFailed event, fails on an event key it does
not know and on a vector count the file did not declare. WriteTracker settles a
write sent after sessionEnd as session_ended. Source ids on a raw document
record are dropped when blank by Unicode White_Space (isBlankText), not Kotlin's
isNullOrBlank, which keeps U+0085 and drops U+001C to U+001F; the vector file's
JSON reader skips JSON whitespace only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:37:48 +00:00
Libby
1136080aaf Merge remote-tracking branch 'origin/libby/write-receipt-text-blank-rules' into libby/write-receipt-kt-interop 2026-11-09 10:34:56 +00:00
Libby
636b387cf1 write-receipt spec: a repeated or unreceived sequenced packet draws no second answer
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 16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:31 +00:00
Libby
6c441d1cda Rust RUDP server: answer a sequenced write once, not once per send
A repeat of a packet the session already took, and a packet too far ahead to be
remembered, delivered no frame and so drew the superseded-write refusal again on
every send, breaking write-receipt rule 1. The refusal now applies only to the
first packet the session takes for a sequence; CultNetRudpSession::has_received
exposes that test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:18 +00:00
Libby
34cceedf98 Rust RUDP server: pin the superseded-write refusal against a releasing packet and the half-space bound
cargo-mutants on the previous commit missed two mutants: `&&` to `||` in the
superseded-packet check (a packet that also releases another write hid it) and
`<` to `<=` at the half-space bound of sequence_at_or_before. A test and two
assertions kill them; the rerun catches 14 of 15 (1 unviable).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:18 +00:00
Libby
4e89f07bff Rust RUDP server: answer a superseded sequenced write; order frame and packet by serial arithmetic
Superseded packet: a reliable-sequenced packet that arrives after a newer one
delivers no frame, yet is acknowledged and never resent, so its write went
unanswered. The test (red first: only "new" was refused, "old" got nothing)
showed the drop happens in the session below frame decoding, but an unfragmented
packet carries its whole frame, so the server can read the id from the packet
payload. Chosen: refuse it from the packet payload. A superseded fragment of a
larger write has no readable id in the packet, so write-receipt.md now says that
write may go unanswered and the client's unconfirmed outcome covers it.

Ordering: frame.sequence <= packet.sequence is replaced by sequence_at_or_before,
serial arithmetic modulo 2^32. Deviation from the brief: cultnet-rs'
at_or_before carries a 4096-sequence receive window made for duplicate detection;
a frame's first fragment may lie further back than that, so reusing it would
leave such a sequenced write unrefused and applied. The new test pins that, and a
hand mutant with the window fails it. A wrapped sequence cannot reach this
compare through the session today (it is judged a duplicate), so the unit test
pins the helper, not an end-to-end wrap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:18 +00:00
Libby
bd2aff8ed0 Rust RUDP server: decide the sequenced-channel refusal per frame
A sequenced packet's arrival can release held ordered frames in the same delivery; refuse only the packet's own frame.
heimdall-low-refuse-sequenced-per-frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:04 +00:00
Libby
4f6ee0db83 Rust RUDP server: pin the refusal of blank schemaId/recordKey on receipt sessions
heimdall-low-rust-keys-untested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 09:13:04 +00:00
Libby
04207007d6 interop test: read the store under the record key the blank-field vector set
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 3m0s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
A vector that sets recordKey (zero_width_space_record_key_is_a_name) replaces the harness key, so the admitted case waited for
blank:<name> and timed out against the Rust server, and the refused record-key cases counted applications under a key the server
never saw. Use the key the document carried.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 07:45:35 +00:00
Libby
ec8ecd2fb0 Merge write-receipt-text-blank-rules (3b1b7697) into libby/write-receipt-py-interop
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
2026-11-09 07:38:23 +00:00
Libby
80f1cfa385 Merge write-receipt-text-blank-rules (3b1b7697) into libby/write-receipt-kt-interop
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
2026-11-09 07:38:23 +00:00
Libby
4b5f343a44 Merge write-receipt-text-blank-rules (3b1b7697) into libby/write-receipt-ts-interop 2026-11-09 07:38:23 +00:00
Libby
140eca5138 cultmesh-ts: TS write-receipt client against the real Rust and C# receipt servers (test:interop)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m56s
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 22:06:55 +00:00
Libby
e4d2446dbe C# InteropPeer: receipt-serve (CultNetDatabaseServer on an ephemeral port) and receipt-relay (LiteNetLib byte shim for the TS client)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 22:06:55 +00:00
Libby
56b0c1679c cultmesh-rs: receipt_interop_server example, the real RUDP document server on an ephemeral port for the interop harness
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 22:06:55 +00:00
Libby
f6c61b1ebf cultnet-py: decode cultnet.write_admitted.v0 and cultnet.error.v1 against the write-receipt vectors
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 31s
Strict closed-schema decode, content hash helper, hello capability; vectors contentHash, wire and schemaInvalid replayed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 22:06:08 +00:00
Libby
cbb336f501 cultmesh-kotlin: decode write_admitted.v0 and error.v1, content hash, WriteTracker, vector replay
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 26s
Strict decode of the two receipt frames (and error.v0), hello capability test,
the dedupe content hash and the client settle rules, ported from cultnet-ts.
write-receipt-vectors.json replays 38/38 (constants, contentHash, wire,
schemaInvalid, scenarios, v0 limit_reached); dedupe and blankFields are server
inputs and not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 22:03:27 +00:00
34 changed files with 3755 additions and 39 deletions

File diff suppressed because one or more lines are too long

View file

@ -149,6 +149,488 @@
{ "name": "empty_tag_list", "set": { "tags": [] }, "outcome": "admitted" }
]
},
"admittedMessageId": {
"semantics": "Decoder-side rule for a `cultnet.write_admitted.v0` frame's `messageId` (Heimdall's ruling, write-receipt.md). Valid only if it contains at least one Unicode letter (\\p{L}) or number (\\p{N}) AND no Default_Ignorable_Code_Point (DerivedCoreProperties). The compare is RAW: the id is echoed byte for byte, so a decoder neither trims nor normalizes. Each case's id is given as `codePoints` (hex, in order), repeated `repeat` times (default 1), so no lone surrogate appears in this file. Every messageId key of a frame must be valid. A runner builds the frame {schemaVersion write_admitted.v0, messageId, operation put, schemaId note.v0, recordKey note:1} with its own encoder and decodes it with the write_admitted decoder; `refuse` means the decoder refuses the frame the way it refuses an empty or ill-typed messageId, `accept` means it decodes and the id comes back identical. A case with `wireHex` carries the whole frame in MessagePack hex, for an id a language string or this file cannot hold (a lone surrogate is not valid UTF-8; the bytes are ED A0 80) or a frame that repeats a key. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses earlier; byte-level strictness is a property of the reader and is not required of a decoder. Scope: write_admitted only; error.v1 is untouched.",
"cases": [
{
"name": "refuse_braille_blank_2800",
"codePoints": [
"2800"
],
"expect": "refuse",
"why": "U+2800 is Default_Ignorable and not a letter or number"
},
{
"name": "refuse_hangul_filler_3164",
"codePoints": [
"3164"
],
"expect": "refuse",
"why": "U+3164 Default_Ignorable (a letter by category Lo, so DI decides)"
},
{
"name": "refuse_hangul_choseong_filler_115F",
"codePoints": [
"115F"
],
"expect": "refuse",
"why": "U+115F Default_Ignorable (category Lo)"
},
{
"name": "refuse_halfwidth_hangul_filler_FFA0",
"codePoints": [
"FFA0"
],
"expect": "refuse",
"why": "U+FFA0 Default_Ignorable (category Lo)"
},
{
"name": "refuse_combining_grapheme_joiner_034F",
"codePoints": [
"034F"
],
"expect": "refuse",
"why": "U+034F Default_Ignorable"
},
{
"name": "refuse_combining_mark_alone_0300",
"codePoints": [
"0300"
],
"expect": "refuse",
"why": "a combining mark is not a letter or number"
},
{
"name": "refuse_private_use_E000",
"codePoints": [
"E000"
],
"expect": "refuse",
"why": "private use is neither a letter nor a number"
},
{
"name": "refuse_unassigned_0378",
"codePoints": [
"0378"
],
"expect": "refuse",
"why": "unassigned since Unicode 1.1, neither a letter nor a number"
},
{
"name": "refuse_lone_surrogate_D800",
"codePoints": [
"D800"
],
"expect": "refuse",
"why": "a lone surrogate is not a Unicode scalar value and has no letter or number; on the wire it is the invalid UTF-8 bytes ED A0 80 in a str. A reader that rejects invalid UTF-8 refuses the frame; one that substitutes or keeps the surrogate refuses the id",
"wireHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A3EDA080A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"
},
{
"name": "refuse_control_nul_0000",
"codePoints": [
"0000"
],
"expect": "refuse",
"why": "a control character is neither a letter nor a number"
},
{
"name": "refuse_control_bel_0007",
"codePoints": [
"0007"
],
"expect": "refuse",
"why": "a control character is neither a letter nor a number"
},
{
"name": "refuse_control_unit_separator_001F",
"codePoints": [
"001F"
],
"expect": "refuse",
"why": "a control character is neither a letter nor a number"
},
{
"name": "refuse_control_delete_007F",
"codePoints": [
"007F"
],
"expect": "refuse",
"why": "a control character is neither a letter nor a number"
},
{
"name": "refuse_control_next_line_0085",
"codePoints": [
"0085"
],
"expect": "refuse",
"why": "a control character is neither a letter nor a number"
},
{
"name": "refuse_empty",
"codePoints": [],
"expect": "refuse",
"why": "empty"
},
{
"name": "refuse_whitespace_only_space_0020",
"codePoints": [
"0020"
],
"expect": "refuse",
"why": "white space only"
},
{
"name": "refuse_whitespace_only_nbsp_00A0",
"codePoints": [
"00A0"
],
"expect": "refuse",
"why": "white space only"
},
{
"name": "refuse_whitespace_only_ideographic_space_3000",
"codePoints": [
"3000"
],
"expect": "refuse",
"why": "white space only"
},
{
"name": "refuse_letter_zwsp_letter",
"codePoints": [
"0061",
"200B",
"0062"
],
"expect": "refuse",
"why": "a Default_Ignorable (U+200B) beside letters"
},
{
"name": "refuse_letter_cgj",
"codePoints": [
"0061",
"034F"
],
"expect": "refuse",
"why": "a Default_Ignorable (U+034F) after a letter"
},
{
"name": "refuse_letter_soft_hyphen",
"codePoints": [
"0061",
"00AD"
],
"expect": "refuse",
"why": "U+00AD is Default_Ignorable"
},
{
"name": "refuse_letter_zwj",
"codePoints": [
"0061",
"200D"
],
"expect": "refuse",
"why": "U+200D is Default_Ignorable"
},
{
"name": "refuse_digit_mongolian_vowel_separator",
"codePoints": [
"0031",
"180E"
],
"expect": "refuse",
"why": "U+180E is Default_Ignorable since Unicode 6.3"
},
{
"name": "refuse_digit_word_joiner_digit",
"codePoints": [
"0031",
"2060",
"0032"
],
"expect": "refuse",
"why": "U+2060 is Default_Ignorable"
},
{
"name": "refuse_bom_before_letter",
"codePoints": [
"FEFF",
"0061"
],
"expect": "refuse",
"why": "U+FEFF is Default_Ignorable; JavaScript trim() would strip it and accept the rest, but the compare is raw"
},
{
"name": "accept_unicode16_gurung_khema_letter_16100",
"codePoints": [
"16100"
],
"expect": "accept",
"why": "U+16100 GURUNG KHEMA LETTER A is Lo and new in Unicode 16.0 (unassigned in 15.1); the rule is pinned to 16.0 in every runtime"
},
{
"name": "accept_unicode16_garay_digit_zero_10D40",
"codePoints": [
"10D40"
],
"expect": "accept",
"why": "U+10D40 GARAY DIGIT ZERO is Nd and new in Unicode 16.0"
},
{
"name": "accept_unicode15_1_cjk_ext_i_2EBF0",
"codePoints": [
"2EBF0"
],
"expect": "accept",
"why": "U+2EBF0, first of CJK Unified Ideographs Extension I, is Lo and new in Unicode 15.1"
},
{
"name": "accept_last_cjk_ext_i_2EE5D",
"codePoints": [
"2EE5D"
],
"expect": "accept",
"why": "U+2EE5D is the last assigned code point of Extension I (Lo)"
},
{
"name": "refuse_after_cjk_ext_i_2EE5E",
"codePoints": [
"2EE5E"
],
"expect": "refuse",
"why": "U+2EE5E is unassigned in Unicode 16.0, so neither a letter nor a number"
},
{
"name": "refuse_unassigned_in_16_0_10D90",
"codePoints": [
"10D90"
],
"expect": "refuse",
"why": "U+10D90 follows the Garay block and is unassigned in Unicode 16.0"
},
{
"name": "refuse_duplicate_message_id_second_is_default_ignorable",
"codePoints": ["0061"],
"expect": "refuse",
"why": "the frame carries messageId twice, \"a\" and then U+200B; every messageId key must be valid, and a reader that keeps the last one sees U+200B",
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A3E2808B"
},
{
"name": "refuse_letter_then_lone_surrogate_D800",
"codePoints": ["0061"],
"expect": "refuse",
"why": "\"a\" followed by a lone surrogate: the id as decoded is not well-formed Unicode. A reader that rejects invalid UTF-8 refuses the frame earlier; one that substitutes or keeps the surrogate must still refuse the id. Byte-level strictness is a property of the reader, not required of the decoder",
"wireHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A461EDA080A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"
},
{
"name": "accept_astral_mathematical_bold_capital_a_1D400",
"codePoints": ["1D400"],
"expect": "accept",
"why": "an astral letter (Lu)"
},
{
"name": "accept_astral_cjk_ext_b_20000",
"codePoints": ["20000"],
"expect": "accept",
"why": "an astral letter (Lo)"
},
{
"name": "refuse_letter_then_default_ignorable_061C",
"codePoints": ["0061", "061C"],
"expect": "refuse",
"why": "U+061C is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_17B4",
"codePoints": ["0061", "17B4"],
"expect": "refuse",
"why": "U+17B4 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_17B5",
"codePoints": ["0061", "17B5"],
"expect": "refuse",
"why": "U+17B5 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_202A",
"codePoints": ["0061", "202A"],
"expect": "refuse",
"why": "U+202A is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_202E",
"codePoints": ["0061", "202E"],
"expect": "refuse",
"why": "U+202E is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_FE00",
"codePoints": ["0061", "FE00"],
"expect": "refuse",
"why": "U+FE00 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_FE0F",
"codePoints": ["0061", "FE0F"],
"expect": "refuse",
"why": "U+FE0F is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_FFF0",
"codePoints": ["0061", "FFF0"],
"expect": "refuse",
"why": "U+FFF0 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_FFF8",
"codePoints": ["0061", "FFF8"],
"expect": "refuse",
"why": "U+FFF8 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_1BCA0",
"codePoints": ["0061", "1BCA0"],
"expect": "refuse",
"why": "U+1BCA0 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_1BCA3",
"codePoints": ["0061", "1BCA3"],
"expect": "refuse",
"why": "U+1BCA3 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_1D173",
"codePoints": ["0061", "1D173"],
"expect": "refuse",
"why": "U+1D173 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_1D17A",
"codePoints": ["0061", "1D17A"],
"expect": "refuse",
"why": "U+1D17A is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_E0000",
"codePoints": ["0061", "E0000"],
"expect": "refuse",
"why": "U+E0000 is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "refuse_letter_then_default_ignorable_E0FFF",
"codePoints": ["0061", "E0FFF"],
"expect": "refuse",
"why": "U+E0FFF is Default_Ignorable (an end of one of its ranges)"
},
{
"name": "accept_abc",
"codePoints": [
"0061",
"0062",
"0063"
],
"expect": "accept",
"why": "letters"
},
{
"name": "accept_digit_1",
"codePoints": [
"0031"
],
"expect": "accept",
"why": "a number"
},
{
"name": "accept_padded_a_raw_compare",
"codePoints": [
"0020",
"0061",
"0020"
],
"expect": "accept",
"why": "raw compare: the id is echoed byte for byte, never trimmed, so the padding stays and the letter makes it valid"
},
{
"name": "accept_e_acute_precomposed",
"codePoints": [
"00E9"
],
"expect": "accept",
"why": "a letter"
},
{
"name": "accept_arabic_indic_digit_three",
"codePoints": [
"0663"
],
"expect": "accept",
"why": "category Nd, a number"
},
{
"name": "accept_cjk_zhong",
"codePoints": [
"4E2D"
],
"expect": "accept",
"why": "a letter (Lo, not Default_Ignorable)"
},
{
"name": "accept_letter_then_combining_acute",
"codePoints": [
"0065",
"0301"
],
"expect": "accept",
"why": "a combining mark after a letter is allowed"
},
{
"name": "accept_letter_nul_letter",
"codePoints": [
"0061",
"0000",
"0062"
],
"expect": "accept",
"why": "U+0000 is not Default_Ignorable and a letter is present, so the rule accepts; raw compare keeps the NUL"
},
{
"name": "accept_256_ascii_letters",
"codePoints": [
"0061"
],
"expect": "accept",
"why": "256 bytes is the size limit and is allowed",
"repeat": 256
}
]
},
"admittedMessageIdDuplicates": {
"semantics": "A frame with more than one messageId key is malformed. A decoder that can see the duplicate (C#, Rust: raw MessagePack reader or ordered pairs) refuses it, whatever the values (`seesDuplicate`). A decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses exactly when that value is invalid (`lastKeyWins`); where it accepts it echoes the last key's id (`lastKeyWinsEcho`). Neither behaviour ever echoes an invalid id. Each runtime declares which policy it has and its runner asserts that policy's column, so a change of a decoder's key policy fails a named test. The first key is ZWSP (U+200B, Default_Ignorable) in `invalid_then_valid`; the second key is the last entry of the map. Frames are MessagePack hex.",
"cases": [
{
"name": "invalid_then_valid",
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A3E2808BA96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A161",
"seesDuplicate": "refuse",
"lastKeyWins": "accept",
"lastKeyWinsEcho": "a"
},
{
"name": "valid_then_invalid",
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A161A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A3E2808B",
"seesDuplicate": "refuse",
"lastKeyWins": "refuse"
},
{
"name": "valid_then_valid",
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A161A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A162",
"seesDuplicate": "refuse",
"lastKeyWins": "accept",
"lastKeyWinsEcho": "b"
}
]
},
"schemaInvalid": [
{
"name": "write_admitted_with_an_error_key",
@ -1182,7 +1664,9 @@
"wire": 7,
"schemaInvalid": 7,
"contentHashCases": 4,
"scenarioCases": 25
"scenarioCases": 25,
"admittedMessageIdCases": 59,
"admittedMessageIdDuplicateCases": 3
},
"contentHash": {
"definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase",

View file

@ -12,6 +12,10 @@ A put or delete today has two answers on the wire: nothing (admitted, or the ser
**`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.
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. The letters, numbers and Default_Ignorable code points are those of **Unicode 16.0** in every runtime, taken from tables embedded in each runtime and generated by `scripts/gen-unicode-messageid-tables.py` from `DerivedGeneralCategory.txt` (Lu Ll Lt Lm Lo Nd Nl No) and `DerivedCoreProperties.txt`; no runtime asks its platform's Unicode data, so a runtime's own Unicode version cannot change a result. Bumping the version is one coordinated change: regenerate the five tables and update the vectors that pin the version in the same commit. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses the frame earlier; byte-level strictness is a property of the reader and is not required of a decoder. A frame with more than one `messageId` key is malformed: a decoder that can see the duplicate (the C# and Rust decoders) refuses it; a decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses if that value is invalid, so no decoder echoes an invalid id (vectors `admittedMessageIdDuplicates` declare each policy and every runner asserts its own). A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
**Where a received `messageId` decides, and what that relies on.** Beyond echoing it, a received `messageId` is used in exactly two places. On a server, the remembered-write key `(actor, messageId)` with the content hash, the per-session limit and the replay of rule 5 are taken from the id of the write frame the server itself parsed. On a client, the write tracker attributes a `write_admitted` or `error.v1` to a write by equality with the id of a write that client itself sent (a frame naming an id not in flight settles nothing), and attributes an id-less error by the count of unanswered writes, not by any id. Both rely on the receiver being the only parser of that frame: a decoder's `messageId` on a frame that carries more than one `messageId` key is not trusted for any authorization, dedupe, replay or ordering decision, and the map-collapsing runtimes (Python, TypeScript, Kotlin, and C#'s typed deserializer on write frames) cannot see the duplicate. If a frame is ever parsed by two runtimes in a chain (a relay, proxy or forwarder that re-reads it), or a consumer needs `messageId` for authorization, dedupe, replay or ordering beyond echo and equality with a self-sent id, all runtimes must first refuse a duplicate `messageId` key (a spec change plus byte-level duplicate detection in the runtimes that decode to a map) before that use lands. Today Rust refuses a repeated key on write frames and on `write_admitted` (serde's `duplicate field messageId`, and `refuse_duplicate_keys` for the raw-list frames such as `document_put_raw`), though its `readable_message_id` names the first key when it names an unreadable frame; C# does not on write frames (a duplicate-key write is deduped on its last id, while the id it names in a refusal for an unreadable frame is read from the first key), and the Python server (`cultmesh_py/server.py`) only echoes the id while the TypeScript and Kotlin code matches it by equality with a request it sent.
**`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.
@ -38,7 +42,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
@ -65,7 +69,7 @@ Those clients complete *successfully* when the window ends with no refusal. Unde
## Vectors
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; a lone surrogate, and a frame that repeats the key, are carried as whole frames in `wireHex`, because a lone surrogate is not valid UTF-8 and only some runtimes' strings can hold it; `write-admitted-message-id-ranges.json` lists every range of the embedded tables, and each runtime probes the first and last point of each range and the points either side), `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
@ -78,6 +82,8 @@ Those clients complete *successfully* when the window ends with no refusal. Unde
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.
Cross-runtime check: `packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts` (`npm run test:interop` in `packages/cultmesh-ts`) runs the TypeScript client (`WriteTracker`, the decoder, the one-shot writer) against the real Rust RUDP document server and the real `CultNetDatabaseServer` over loopback, one scenario set against both. Known gap it records as a todo: the Rust RUDP document server refuses every delete (`write_not_authorized: the server does not take deletes`), where this spec has an admitted delete receipted like a put.
## 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

@ -474,3 +474,14 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
It also runs the built-in RUDP packet fixture and localhost UDP socket
self-test.
## Write Receipt
`WriteReceipt.kt` decodes `cultnet.write_admitted.v0` and `cultnet.error.v1` (and `error.v0`) strictly against their
closed schemas (`decodeWriteAdmitted`, `decodeErrorFrame`), computes the dedupe `writeContentHash`, tests a hello with
`helloAdvertisesWriteReceipts`, and settles sent writes as admitted, refused or unconfirmed in `WriteTracker`
(pure state machine, abstract clock; the caller owns transport and timers). Kotlin has no write client yet, so nothing
sends a put and waits on this. `WriteReceiptVectors.kt` replays
`contracts/cultnet/interop/write-receipt-vectors.json` (constants, contentHash, wire, schemaInvalid, scenarios; the
server-side `dedupe` and `blankFields` sections are not run); `build.ps1` runs it after the self-test, or run
`CultMeshKt write-receipt-vectors <path>`.

View file

@ -26,4 +26,7 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
$vectors = Join-Path $repoRoot "contracts\cultnet\interop\write-receipt-vectors.json"
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt write-receipt-vectors $vectors
if ($LASTEXITCODE -ne 0) { throw "write-receipt vector replay failed with exit code $LASTEXITCODE" }
Write-Host "Built $jarPath"

View file

@ -19,6 +19,7 @@ import java.net.Socket
import java.net.SocketTimeoutException
import java.net.URI
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.security.MessageDigest
@ -1100,9 +1101,9 @@ data class CultNetRawDocumentRecord(
"payloadEncoding" to payloadEncoding,
"payload" to payload.copyOf(),
)
if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId
if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId
if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole
if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId
if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId
if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole
if (tags.isNotEmpty()) wire["tags"] = tags
return wire
}
@ -2364,6 +2365,9 @@ fun parseCultNetMessage(payload: ByteArray): CultNetMessage {
if (schemaVersion !is String || schemaVersion.isBlank()) {
throw IOException("CultNet schema-v0 messages must declare schemaVersion")
}
// A write_admitted id is echoed byte for byte, so that frame alone is read again with invalid UTF-8 refused; document payload
// strings elsewhere keep decoding as before.
if (schemaVersion == WRITE_ADMITTED_VERSION) MessagePackReader(payload, strictUtf8 = true).readAny()
val body = linkedMapOf<String, Any?>()
for ((key, value) in decoded) {
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
@ -3413,6 +3417,7 @@ fun main(args: Array<String>) {
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
return
}
@ -3424,6 +3429,7 @@ fun main(args: Array<String>) {
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
"serve" -> interopServe(options)
"dial" -> interopDial(options)
"write-receipt-vectors" -> if (!replayWriteReceiptVectors(args.getOrNull(1) ?: error("usage: write-receipt-vectors <vectors.json>"))) kotlin.system.exitProcess(1)
else -> error("Unknown mode ${args[0]}")
}
}
@ -6149,7 +6155,8 @@ private fun requireSchemaResponse(message: CultNetMessage?, expectedSchemaVersio
return response
}
class MessagePackReader(payload: ByteArray) {
/** [strictUtf8] makes a str whose bytes are not UTF-8 throw instead of becoming U+FFFD; only the write_admitted frame asks for it. */
class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = false) {
private val input = DataInputStream(ByteArrayInputStream(payload))
private var pushed = -1
@ -6304,7 +6311,10 @@ class MessagePackReader(payload: ByteArray) {
code == 0xdb -> input.readInt()
else -> throw IOException("expected string")
}
return String(readPayload(length), StandardCharsets.UTF_8)
val bytes = readPayload(length)
if (!strictUtf8) return String(bytes, StandardCharsets.UTF_8)
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString()
}
private fun readBinaryAfterCode(code: Int): ByteArray {

View file

@ -0,0 +1,129 @@
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
package org.gamecult.cultmesh
// (low, high) pairs, flattened and sorted.
internal val LETTER_OR_NUMBER: IntArray = intArrayOf(
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
0x323AF,
)
internal val DEFAULT_IGNORABLE: IntArray = intArrayOf(
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
)

View file

@ -0,0 +1,286 @@
package org.gamecult.cultmesh
// The write receipt (contracts/cultnet/interop/write-receipt.md): the two frames, their strict decode, the dedupe
// content hash, the hello capability test and the client settle rules. Mirrors packages/cultnet-ts (contracts.ts,
// write-tracker.ts) and packages/cultnet-rs (write_content_hash). The replay of write-receipt-vectors.json lives in
// WriteReceiptVectors.kt. Server rules (dedupe, limits, blank fields) are not a decoder's business and are not here.
import java.io.IOException
import java.security.MessageDigest
const val WRITE_ADMITTED_VERSION = "cultnet.write_admitted.v0"
const val ERROR_V1_VERSION = "cultnet.error.v1"
const val ERROR_V0_VERSION = "cultnet.error.v0"
/** A connection is receipt-capable only when its hello lists both frames. */
val CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: List<String> = listOf(WRITE_ADMITTED_VERSION, ERROR_V1_VERSION)
const val RECEIPT_RESULT_WINDOW_MS = 5000L
const val LEGACY_RESULT_WINDOW_MS = 500L
fun resultWindowMs(serverAdvertisesReceipts: Boolean): Long =
if (serverAdvertisesReceipts) RECEIPT_RESULT_WINDOW_MS else LEGACY_RESULT_WINDOW_MS
/** The closed refusal-code set of error.v0; error.v1 adds `limit_reached`. */
val CULTNET_ERROR_V0_CODES: Set<String> = setOf(
"selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported",
"unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid",
)
val CULTNET_ERROR_V1_CODES: Set<String> = CULTNET_ERROR_V0_CODES + "limit_reached"
fun helloAdvertisesWriteReceipts(hello: CultNetMessage): Boolean {
val versions = hello.body["supportedMessageVersions"] as? List<*> ?: return false
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.all { it in versions }
}
data class CultNetWriteAdmitted(val messageId: String, val operation: String, val schemaId: String, val recordKey: String) {
fun toMessage() = CultNetMessage(
WRITE_ADMITTED_VERSION,
linkedMapOf("messageId" to messageId, "operation" to operation, "schemaId" to schemaId, "recordKey" to recordKey),
)
}
data class CultNetErrorDetails(val field: String?, val value: String?, val asOf: Long?, val current: Long?)
/** error.v0 (`messageId` always null, `version` 0) or error.v1 (`version` 1). */
data class CultNetErrorFrame(
val version: Int,
val error: String,
val routingHint: Map<String, Any?>?,
val code: String?,
val details: CultNetErrorDetails?,
val messageId: String?,
) {
fun toMessage(): CultNetMessage {
val body = linkedMapOf<String, Any?>(
"error" to error,
"routingHint" to routingHint,
"code" to code,
"details" to details?.let {
linkedMapOf("field" to it.field, "value" to it.value, "asOf" to it.asOf, "current" to it.current)
},
)
if (version == 1) body["messageId"] = messageId
return CultNetMessage(if (version == 1) ERROR_V1_VERSION else ERROR_V0_VERSION, body)
}
}
/**
* Strict decode of a write_admitted.v0 message against its closed schema: exactly five keys, a non-empty string each,
* operation put|delete.
*/
fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted {
if (message.schemaVersion != WRITE_ADMITTED_VERSION) throw IOException("not a $WRITE_ADMITTED_VERSION message")
closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey"))
val operation = nonEmpty(message, "operation")
if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete")
return CultNetWriteAdmitted(admittedMessageId(message), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey"))
}
// write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
// Unicode 16.0, compared raw (never trimmed). The tables are embedded (UnicodeMessageIdTables.kt, generated by
// scripts/gen-unicode-messageid-tables.py), not the JDK's, so the running JVM's Unicode version changes no result; the other
// four runtimes embed the same generated tables.
private fun inRanges(ranges: IntArray, point: Int): Boolean {
var low = 0
var high = ranges.size / 2 - 1
while (low <= high) {
val mid = (low + high) ushr 1
when {
point < ranges[2 * mid] -> high = mid - 1
point > ranges[2 * mid + 1] -> low = mid + 1
else -> return true
}
}
return false
}
private fun admittedMessageId(message: CultNetMessage): String {
val id = nonEmpty(message, "messageId")
var hasLetterOrNumber = false
id.codePoints().forEach { cp ->
if (inRanges(DEFAULT_IGNORABLE, cp)) throw IOException("messageId holds a Default_Ignorable code point")
if (inRanges(LETTER_OR_NUMBER, cp)) hasLetterOrNumber = true
}
if (!hasLetterOrNumber) throw IOException("messageId must contain a letter or number")
return id
}
/** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */
fun decodeErrorFrame(message: CultNetMessage): CultNetErrorFrame {
val version = when (message.schemaVersion) {
ERROR_V1_VERSION -> 1
ERROR_V0_VERSION -> 0
else -> throw IOException("not an error message")
}
closedKeys(message, setOf("error", "routingHint", "code", "details") + if (version == 1) setOf("messageId") else emptySet())
val error = nonEmpty(message, "error")
val codeValue = message.body["code"]
if (codeValue != null && (codeValue !is String || codeValue !in (if (version == 1) CULTNET_ERROR_V1_CODES else CULTNET_ERROR_V0_CODES))) {
throw IOException("unknown error code")
}
var messageId: String? = null
if (version == 1) {
val id = message.body["messageId"]
if (id != null && (id !is String || id.isEmpty())) throw IOException("messageId must be a non-empty string or nil")
messageId = id as String?
}
return CultNetErrorFrame(version, error, routingHintOf(message.body["routingHint"]), codeValue as String?, detailsOf(message.body["details"]), messageId)
}
private fun closedKeys(message: CultNetMessage, required: Set<String>) {
if (!message.body.keys.containsAll(required)) throw IOException("${message.schemaVersion} is missing ${required - message.body.keys}")
val unknown = message.body.keys - required
if (unknown.isNotEmpty()) throw IOException("${message.schemaVersion} carries unknown keys $unknown")
}
private fun nonEmpty(message: CultNetMessage, key: String): String {
val value = message.body[key]
if (value !is String || value.isEmpty()) throw IOException("$key must be a non-empty string")
return value
}
private fun routingHintOf(value: Any?): Map<String, Any?>? {
if (value == null) return null
if (value !is Map<*, *> || value.keys != setOf("shard", "reason")) throw IOException("routingHint must be nil or {shard, reason}")
if (value["shard"] != null && value["shard"] !is Map<*, *>) throw IOException("routingHint.shard must be an object or nil")
if (value["reason"] !is String) throw IOException("routingHint.reason must be a string")
@Suppress("UNCHECKED_CAST")
return value as Map<String, Any?>
}
private fun detailsOf(value: Any?): CultNetErrorDetails? {
if (value == null) return null
if (value !is Map<*, *> || value.keys != setOf("field", "value", "asOf", "current")) {
throw IOException("details must be nil or {field, value, asOf, current}")
}
fun text(key: String): String? = value[key].let { if (it == null || it is String) it as String? else throw IOException("details.$key must be a string or nil") }
fun cursor(key: String): Long? = value[key].let {
if (it == null) null else if (it is Long && it >= 0) it else throw IOException("details.$key must be a non-negative integer or nil")
}
return CultNetErrorDetails(text("field"), text("value"), cursor("asOf"), cursor("current"))
}
/** sha256 of the MessagePack array [operation, schemaId, recordKey, payload]: payload a bin for a put, nil for a delete. Lowercase hex. */
fun writeContentHash(operation: String, schemaId: String, recordKey: String, payload: ByteArray?): String {
val writer = MessagePackWriter().array(4).string(operation).string(schemaId).string(recordKey)
if (payload == null) writer.nil() else writer.binary(payload)
return MessageDigest.getInstance("SHA-256").digest(writer.toByteArray()).joinToString("") { "%02x".format(it) }
}
/** Why nothing settled a write. */
enum class WriteUnconfirmedReason(val wire: String) { WINDOW_ELAPSED("window_elapsed"), SESSION_ENDED("session_ended"), LOCAL_SEND_FAILED("local_send_failed") }
/** What a late answer is recorded as on a write that already settled unconfirmed. */
enum class WriteRecorded(val wire: String) { ADMITTED_LATE("admitted_late"), REFUSED_LATE("refused_late") }
/** What the caller was told. Settled once; never changed by a later frame. */
sealed class WriteSettlement {
object Admitted : WriteSettlement()
data class Refused(val code: String?, val error: String, val response: CultNetErrorFrame) : WriteSettlement()
data class Unconfirmed(val reason: WriteUnconfirmedReason) : WriteSettlement()
}
/**
* The client half of the receipt rules: a pure state machine with an abstract clock, one decision, how each sent write
* settles. Transport, timers and decoding belong to the caller; `frame` takes a message and decodes it strictly.
*/
class WriteTracker(private val windowMs: Long, private val onSettle: (String, WriteSettlement) -> Unit = { _, _ -> }) {
private class Entry(val sentAtMs: Long) {
var settlement: WriteSettlement? = null
var recorded: WriteRecorded? = null
}
private val writes = linkedMapOf<String, Entry>()
private var ended = false
/** Registers a write sent at [atMs]. Re-sending a known id is the same write and does not restart its window. */
fun send(messageId: String, atMs: Long) {
tick(atMs)
if (messageId in writes) return
val entry = Entry(atMs)
writes[messageId] = entry
// On a session that already ended nothing can answer this write.
if (ended) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
}
/** Feeds one received message. Only write_admitted, error.v1 and error.v0 answer; anything else is ignored. */
fun frame(message: CultNetMessage, atMs: Long) {
tick(atMs)
when (message.schemaVersion) {
WRITE_ADMITTED_VERSION -> {
answer(decodeWriteAdmitted(message).messageId, WriteSettlement.Admitted, WriteRecorded.ADMITTED_LATE)
return
}
ERROR_V1_VERSION, ERROR_V0_VERSION -> {
val error = decodeErrorFrame(message)
val refusal = WriteSettlement.Refused(error.code, error.error, error)
if (error.messageId != null) {
answer(error.messageId, refusal, WriteRecorded.REFUSED_LATE)
return
}
// An error that names no write is attributable only when exactly one write is unanswered.
val unanswered = writes.filterValues { it.settlement == null }
if (unanswered.size == 1) settle(unanswered.keys.first(), unanswered.values.first(), refusal)
}
else -> return
}
}
/** Every unanswered write whose window, counted from its own send, has run settles unconfirmed. */
fun tick(atMs: Long) {
for ((id, entry) in writes) {
if (entry.settlement == null && atMs - entry.sentAtMs >= windowMs) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.WINDOW_ELAPSED))
}
}
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
fun sessionEnd(atMs: Long) {
tick(atMs)
ended = true
for ((id, entry) in writes) if (entry.settlement == null) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
}
/** This client's own send of [messageId] failed: that write settles unconfirmed; the others are untouched. */
fun sendFailed(messageId: String, atMs: Long) {
val entry = writes[messageId]
if (entry != null && entry.settlement == null) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.LOCAL_SEND_FAILED))
tick(atMs)
}
/** The settlement told to the caller, or null while unanswered or unknown. */
fun settlement(messageId: String): WriteSettlement? = writes[messageId]?.settlement
/** The late answer recorded on an unconfirmed write. */
fun recorded(messageId: String): WriteRecorded? = writes[messageId]?.recorded
private fun answer(messageId: String, settlement: WriteSettlement, late: WriteRecorded) {
val entry = writes[messageId] ?: return // names a write that is not in flight: settles nothing
val current = entry.settlement
if (current == null) settle(messageId, entry, settlement)
else if (current is WriteSettlement.Unconfirmed && entry.recorded == null) entry.recorded = late
}
private fun settle(messageId: String, entry: Entry, settlement: WriteSettlement) {
entry.settlement = settlement
onSettle(messageId, settlement)
}
}
/** Unicode White_Space (PropList), the one definition of "blank" in write-receipt.md rule 5; Kotlin's own isBlank takes U+001C to U+001F. */
private val WHITE_SPACE_CODE_POINTS: Set<Int> = (0x0009..0x000D).toSet() + 0x0020 + 0x0085 + 0x00A0 + 0x1680 +
(0x2000..0x200A).toSet() + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000
/** True for null, empty, or only White_Space code points. */
fun isBlankText(text: String?): Boolean = text == null || text.codePoints().allMatch { it in WHITE_SPACE_CODE_POINTS }
/** Self-test: blank is White_Space, not Kotlin's isBlank (rule 5): U+001C to U+001F name something, U+00A0 and U+0085 do not. */
fun rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() {
fun wireKeys(id: String) = CultNetRawDocumentRecord("note.v0", "note:1", "2026-11-08T00:00:00Z", payload = byteArrayOf(1), sourceRuntimeId = id, sourceAgentId = id, sourceRole = id).toWireMap().keys
for (blank in listOf("", "\u0085", "\u00a0\u2003", "\u3000", "\t\r\n ")) {
check(isBlankText(blank) && "sourceRuntimeId" !in wireKeys(blank)) { "blank ${blank.map { it.code }} must be dropped" }
}
for (named in listOf("\u001c", "\u001f", "\u200b", "\ufeff", "\u180e", "a")) {
check(!isBlankText(named) && "sourceRuntimeId" in wireKeys(named) && "sourceRole" in wireKeys(named)) { "${named.map { it.code }} names something and must be kept" }
}
}

View file

@ -0,0 +1,310 @@
package org.gamecult.cultmesh
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: constants, contentHash, every
// `wire` frame (decode to the vector's fields, re-encode to its bytes), every `schemaInvalid` frame (must be refused),
// and every `scenarios` event replay through WriteTracker. `dedupe` and `blankFields` are server inputs and are not
// run here. Mode: `java -cp ... org.gamecult.cultmesh.CultMeshKt write-receipt-vectors <vectors.json>`.
import java.io.File
import java.io.IOException
// The event actions scenarios.semantics names; anything else is refused, not skipped.
private val EVENT_KEYS = listOf("send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck")
fun replayWriteReceiptVectors(path: String): Boolean {
@Suppress("UNCHECKED_CAST")
val vectors = JsonReader(File(path).readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
val results = linkedMapOf<String, List<String>>()
fun check(name: String, run: () -> List<String>) {
results[name] = try { run() } catch (e: Throwable) { listOf("threw: $e") }
}
val constants = obj(vectors["constants"])
check("constants") {
listOfNotNull(
"RECEIPT window".takeIf { constants["RECEIPT_RESULT_WINDOW_MS"] != RECEIPT_RESULT_WINDOW_MS },
"LEGACY window".takeIf { constants["LEGACY_RESULT_WINDOW_MS"] != LEGACY_RESULT_WINDOW_MS },
)
}
for (case in list(obj(vectors["contentHash"])["cases"])) {
val c = obj(case)
check("contentHash/${c["name"]}") {
val got = writeContentHash(c["operation"] as String, c["schemaId"] as String, c["recordKey"] as String, (c["payloadHex"] as String?)?.let(::unhex))
if (got == c["sha256"]) emptyList() else listOf("$got != ${c["sha256"]}")
}
}
for (case in list(vectors["wire"])) {
val c = obj(case)
check("wire/${c["name"]}") {
val bytes = unhex(c["messagePackHex"] as String)
val problems = mutableListOf<String>()
val message = decodeReceiptFrame(parseCultNetMessage(bytes))
if (message.toWireMap() != c["message"]) problems += "decoded ${message.toWireMap()} != ${c["message"]}"
val again = message.toBytes().joinToString("") { "%02X".format(it) }
if (again != (c["messagePackHex"] as String).uppercase()) problems += "re-encoded bytes $again != captured ${c["messagePackHex"]}"
problems
}
}
// admittedMessageId: a write_admitted messageId needs a letter or number and no Default_Ignorable code point, compared
// raw. The frame is built with this runtime's own encoder; a case with `wireHex` (the lone surrogate, invalid UTF-8)
// supplies the whole frame.
for (case in list(obj(vectors["admittedMessageId"])["cases"])) {
val c = obj(case)
check("admittedMessageId/${c["name"]}") {
val id = list(c["codePoints"]).joinToString("") { String(Character.toChars((it as String).toInt(16))) }.repeat(((c["repeat"] as Long?) ?: 1L).toInt())
val bytes = (c["wireHex"] as String?)?.let(::unhex) ?: MessagePackWriter().value(linkedMapOf(
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
)).toByteArray()
val decoded = try { decodeReceiptFrame(parseCultNetMessage(bytes)) } catch (_: IOException) { null }
when {
c["expect"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted an id that must be refused (${c["why"]})")
decoded == null -> listOf("refused an id that must be accepted (${c["why"]})")
decoded.toWireMap()["messageId"] != id -> listOf("id not echoed raw: ${decoded.toWireMap()["messageId"]}")
else -> emptyList()
}
}
}
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every
// range, so deleting or narrowing any range fails here.
check("unicodeTableRanges") {
@Suppress("UNCHECKED_CAST")
val ranges = JsonReader(File(File(path).parentFile, "write-admitted-message-id-ranges.json").readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
fun pairs(key: String) = list(ranges[key]).map { list(it).map { n -> (n as Long).toInt() } }
val letters = pairs("letterOrNumber")
val ignorable = pairs("defaultIgnorable")
val problems = mutableListOf<String>()
if (letters.size <= 700 || ignorable.size != 17) problems += "ranges file holds ${letters.size} and ${ignorable.size} ranges"
fun accepted(id: String): Boolean = try {
decodeReceiptFrame(parseCultNetMessage(MessagePackWriter().value(linkedMapOf(
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
)).toByteArray()))
true
} catch (_: IOException) { false }
for ((low, high) in letters) for ((point, want) in listOf(low to true, high to true, low - 1 to false, high + 1 to false)) {
if (accepted(String(Character.toChars(point))) != want) problems += "U+${point.toString(16)} alone should be ${if (want) "accepted" else "refused"}"
}
for ((low, high) in ignorable) for ((point, want) in listOf(low to false, high to false, low - 1 to true, high + 1 to true)) {
if (accepted("a" + String(Character.toChars(point))) != want) problems += "a + U+${point.toString(16)} should be ${if (want) "accepted" else "refused"}"
}
problems
}
// A document payload string with a lone surrogate (ED A0 80) is not a write_admitted id: the shared MessagePack reader keeps
// reading it. Strictness is scoped to the write_admitted frame, never to the reader.
check("documentPayloadStringWithLoneSurrogateStillDecodes") {
val text = MessagePackReader(unhex("A3EDA080")).readString()
if (text.isNotEmpty()) emptyList() else listOf("decoded to an empty string")
}
// admittedMessageIdDuplicates: Kotlin decodes the frame to a map, so it cannot see a repeated key: it judges the last one.
for (case in list(obj(vectors["admittedMessageIdDuplicates"])["cases"])) {
val c = obj(case)
check("admittedMessageIdDuplicates/${c["name"]}") {
val decoded = try { decodeReceiptFrame(parseCultNetMessage(unhex(c["wireHex"] as String))) } catch (_: IOException) { null }
when {
c["lastKeyWins"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted a frame whose last messageId key is invalid")
decoded == null -> listOf("refused a frame whose last messageId key is valid")
decoded.toWireMap()["messageId"] != c["lastKeyWinsEcho"] -> listOf("echoed ${decoded.toWireMap()["messageId"]}, wanted ${c["lastKeyWinsEcho"]}")
else -> emptyList()
}
}
}
for (case in list(vectors["schemaInvalid"])) {
val c = obj(case)
check("schemaInvalid/${c["name"]}") {
try {
decodeReceiptFrame(parseCultNetMessage(unhex(c["messagePackHex"] as String)))
listOf("accepted a frame that must be refused (${c["why"]})")
} catch (_: IOException) {
emptyList()
}
}
}
// Not a vector: `limit_reached` is in error.v1's enum only (write-receipt.md), as the TS and Rust suites also pin.
check("error_v0_refuses_the_v1_only_limit_reached_code") {
val body = linkedMapOf<String, Any?>("error" to "x", "routingHint" to null, "code" to "limit_reached", "details" to null)
val v1 = decodeErrorFrame(CultNetMessage(ERROR_V1_VERSION, body + ("messageId" to null))).code
try {
decodeErrorFrame(CultNetMessage(ERROR_V0_VERSION, body))
listOf("error.v0 accepted limit_reached")
} catch (_: IOException) {
if (v1 == "limit_reached") emptyList() else listOf("error.v1 lost limit_reached")
}
}
for (case in list(obj(vectors["scenarios"])["cases"])) {
val scenario = obj(case)
check("scenario/${scenario["name"]}") {
val problems = mutableListOf<String>()
// The fake server states its capability the only way a real one does: in its hello.
val hello = cultNetHello(
"fake-server", "fake",
supportedMessageVersions = if (scenario["server"] == "receipt") CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS else emptyList(),
)
val windowMs = resultWindowMs(helloAdvertisesWriteReceipts(hello))
if (windowMs != constants[scenario["window"] as String]) problems += "window $windowMs != ${scenario["window"]}"
val told = linkedMapOf<String, WriteSettlement>()
val tracker = WriteTracker(windowMs) { id, settlement ->
if (id in told) problems += "$id settled twice"
told[id] = settlement
}
for (event in list(scenario["events"]).map(::obj).sortedBy { it["atMs"] as Long }) {
val at = event["atMs"] as Long
val actions = event.keys - "atMs"
if (actions.size != 1 || actions.first() !in EVENT_KEYS) { problems += "event at $at has keys $actions, wanted atMs and exactly one of $EVENT_KEYS"; continue }
when {
"send" in event -> tracker.send(event["send"] as String, at)
// A frame reaches the client as bytes: the fake server encodes it, the client decodes it.
"frame" in event -> tracker.frame(parseCultNetMessage(MessagePackWriter().value(event["frame"]).toByteArray()), at)
"tick" in event -> tracker.tick(at)
"sessionEnd" in event -> tracker.sessionEnd(at)
"sendFailed" in event -> tracker.sendFailed(event["sendFailed"] as String, at)
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
}
}
val expect = obj(scenario["expect"])
for ((id, wantAny) in expect) {
val want = obj(wantAny)
val got = told[id]
if (got == null) { problems += "$id: never settled, wanted ${want["outcome"]}"; continue }
val (outcome, code, reason) = when (got) {
is WriteSettlement.Admitted -> Triple("admitted", null, null)
is WriteSettlement.Refused -> Triple("refused", got.code, null)
is WriteSettlement.Unconfirmed -> Triple("unconfirmed", null, got.reason.wire)
}
if (outcome != want["outcome"]) problems += "$id: told $outcome, wanted ${want["outcome"]}"
if ("code" in want && code != want["code"]) problems += "$id: code $code, wanted ${want["code"]}"
if ("reason" in want && reason != want["reason"]) problems += "$id: reason $reason, wanted ${want["reason"]}"
if (tracker.recorded(id)?.wire != want["recorded"]) problems += "$id: recorded ${tracker.recorded(id)?.wire}, wanted ${want["recorded"]}"
}
for (id in told.keys) if (id !in expect) problems += "$id: settled but the scenario expects nothing for it"
problems
}
}
// A runner that read fewer vectors than the file declares has silently skipped some.
check("counts") {
val declared = obj(vectors["counts"])
fun ran(prefix: String) = results.keys.count { it.startsWith(prefix) }
val loaded = mapOf(
"wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size,
"contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size,
"admittedMessageIdCases" to list(obj(vectors["admittedMessageId"])["cases"]).size,
)
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"), "admittedMessageIdCases" to ran("admittedMessageId/"))
loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] }
.map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" }
}
for ((name, problems) in results) {
println(if (problems.isEmpty()) "PASS $name" else "FAIL $name")
problems.forEach { println(" $it") }
}
val failed = results.values.count { it.isNotEmpty() }
println("${results.size - failed}/${results.size} passed")
return failed == 0
}
/** Strict decode of whichever receipt-family frame this is, returned as the message its typed form encodes; anything else is refused. */
private fun decodeReceiptFrame(message: CultNetMessage): CultNetMessage = when (message.schemaVersion) {
WRITE_ADMITTED_VERSION -> decodeWriteAdmitted(message).toMessage()
ERROR_V1_VERSION, ERROR_V0_VERSION -> decodeErrorFrame(message).toMessage()
else -> throw IOException("not a receipt frame: ${message.schemaVersion}")
}
@Suppress("UNCHECKED_CAST")
private fun obj(value: Any?): Map<String, Any?> = value as Map<String, Any?>
private fun list(value: Any?): List<Any?> = value as List<Any?>
private fun unhex(text: String): ByteArray = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
/** A small JSON reader for the vector file: objects, arrays, strings, integers, true/false/null. */
private class JsonReader(private val text: String) {
private var at = 0
fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") }
private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ }
private fun readValue(): Any? {
skipSpace()
return when (val c = text[at]) {
'{' -> {
at++
val map = linkedMapOf<String, Any?>()
skipSpace()
if (text[at] == '}') { at++; return map }
do {
skipSpace()
val key = readString()
skipSpace(); expect(':')
map[key] = readValue()
skipSpace()
} while (closes('}'))
map
}
'[' -> {
at++
val items = mutableListOf<Any?>()
skipSpace()
if (text[at] == ']') { at++; return items }
do {
items += readValue()
skipSpace()
} while (closes(']'))
items
}
'"' -> readString()
't' -> literal("true", true)
'f' -> literal("false", false)
'n' -> literal("null", null)
else -> {
if (c != '-' && !c.isDigit()) throw IOException("unexpected '$c' at $at")
val start = at++
while (at < text.length && text[at].isDigit()) at++
text.substring(start, at).toLong()
}
}
}
/** After an element: false when [close] ends the container, true after a comma. */
private fun closes(close: Char): Boolean {
val c = text[at++]
if (c == close) return false
if (c != ',') throw IOException("expected , or $close at ${at - 1}")
return true
}
private fun literal(word: String, value: Any?): Any? {
if (!text.startsWith(word, at)) throw IOException("bad literal at $at")
at += word.length
return value
}
private fun expect(c: Char) { if (text[at++] != c) throw IOException("expected $c at ${at - 1}") }
private fun readString(): String {
expect('"')
val out = StringBuilder()
while (true) {
val c = text[at++]
when (c) {
'"' -> return out.toString()
'\\' -> when (val e = text[at++]) {
'n' -> out.append('\n'); 't' -> out.append('\t'); 'r' -> out.append('\r')
'b' -> out.append('\b'); 'f' -> out.append('\u000c')
'u' -> { out.append(text.substring(at, at + 4).toInt(16).toChar()); at += 4 }
'"', '\\', '/' -> out.append(e)
else -> throw IOException("bad escape \\$e")
}
else -> out.append(c)
}
}
}
}

View file

@ -0,0 +1,77 @@
//! The real Rust RUDP document server on an ephemeral loopback port, for the cross-runtime write-receipt interop
//! harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts). It is the production server with a
//! recording sink and nothing else: stdout carries one JSON line per fact the harness asserts on, and the server runs
//! until stdin closes.
//!
//! {"event":"listening","port":N}
//! {"event":"applied","op":"put","messageId":"...","schemaId":"...","recordKey":"..."}
//!
//! Record keys that start with `refuse:` are refused by the sink (authorize and commit), which is how the harness
//! asks for a refused write.
use anyhow::Result;
use cultmesh_rs::{
CultMeshRudpDocumentServer, CultMeshRudpRawDocumentReceipt, CultMeshRudpRawDocumentSink,
CultMeshRudpSnapshotQuery, CultMeshSystemClock,
};
use cultnet_rs::CultNetRawDocumentRecord;
use serde_json::json;
use std::io::Read;
use std::net::UdpSocket;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
const REFUSED_PREFIX: &str = "refuse:";
struct RecordingSink;
impl CultMeshRudpRawDocumentSink for RecordingSink {
fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> {
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
anyhow::bail!("not authorized");
}
Ok(())
}
fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> {
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
anyhow::bail!("not authorized");
}
println!(
"{}",
json!({
"event": "applied",
"op": "put",
"messageId": receipt.message_id,
"schemaId": receipt.document.schema_id,
"recordKey": receipt.document.record_key,
})
);
Ok(())
}
}
fn main() -> Result<()> {
let source = |_: &CultMeshRudpSnapshotQuery| -> Result<Vec<CultNetRawDocumentRecord>> { Ok(Vec::new()) };
let mut server = CultMeshRudpDocumentServer::new(
UdpSocket::bind("127.0.0.1:0")?,
RecordingSink,
source,
CultMeshSystemClock::default(),
Default::default(),
)?;
println!("{}", json!({ "event": "listening", "port": server.local_addr()?.port() }));
let stop = Arc::new(AtomicBool::new(false));
let watcher = Arc::clone(&stop);
thread::spawn(move || {
let mut sink = Vec::new();
let _ = std::io::stdin().read_to_end(&mut sink);
watcher.store(true, Ordering::SeqCst);
});
while !stop.load(Ordering::SeqCst) {
server.poll_once()?;
thread::sleep(Duration::from_millis(1));
}
Ok(())
}

View file

@ -780,6 +780,13 @@ where
return Ok(CultMeshRudpPollOutcome::Handled);
}
// Whether this packet is the first the session takes for its sequence: a repeat of an answered write, or a
// packet too far ahead to be remembered, is not, and the write is not answered for it again.
let first_receipt = packet.reliable
&& self
.sessions
.get(&key)
.is_some_and(|entry| !entry.session.has_received(packet.sequence));
let result = {
let entry = self
.sessions
@ -821,11 +828,28 @@ where
}
let mut kept_rejection = None;
// A superseded packet delivers no frame, yet it was acknowledged and will not be resent, so its write would
// go unanswered. A packet that carries its whole frame is answered from its own payload. A superseded
// fragment cannot be: the write's id may lie in a fragment this packet does not hold.
if refuse_sequenced
&& first_receipt
&& self.sessions.get(&key).is_some_and(|entry| entry.session.has_received(packet.sequence))
&& packet.fragment_count == 0
&& !result.delivered.iter().any(|frame| frame.channel_id == "schema" && frame.sequence == packet.sequence)
{
if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) =
self.refuse_unreliable_write(key, &packet.payload)?
{
kept_rejection = Some(rejection);
}
}
for frame in result.delivered {
if frame.channel_id != "schema" {
continue;
}
if refuse_sequenced {
// `delivered` also holds the ordered frames this packet's arrival released from the hold; those were
// not sent on the sequenced channel. They sit above the packet's own sequence, its own frame at or below.
if refuse_sequenced && sequence_at_or_before(frame.sequence, packet.sequence) {
if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) =
self.refuse_unreliable_write(key, &frame.payload)?
{
@ -1814,6 +1838,13 @@ where
#[cfg(test)]
mod send_failure_tests;
/// Whether `sequence` is at or before `mark` in serial order (RFC 1982): the 32-bit space is compared modulo 2^32,
/// so a frame that began before the wrap and a packet after it still order correctly. The receive window of
/// `cultnet_rs`' duplicate test does not belong here: a frame's first fragment may lie any distance back.
fn sequence_at_or_before(sequence: u32, mark: u32) -> bool {
mark.wrapping_sub(sequence) < 1 << 31
}
fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> {
if options.max_sessions == 0 {
return Err(anyhow!("max_sessions must be greater than zero"));
@ -1929,4 +1960,17 @@ mod tests {
.unwrap();
assert_eq!(hex(&bytes), MESSAGE_INVALID_HEX);
}
#[test]
fn sequence_order_holds_across_the_wrap_and_beyond_the_receive_window() {
assert!(sequence_at_or_before(7, 7));
assert!(sequence_at_or_before(100, 5_200), "further back than the receive window");
assert!(!sequence_at_or_before(5_200, 100));
assert!(sequence_at_or_before(u32::MAX - 1, 1), "begun before the wrap, ended after it");
assert!(!sequence_at_or_before(1, u32::MAX - 1), "after the wrap is later than before it");
assert!(sequence_at_or_before(u32::MAX, 0), "the wrap itself");
assert!(!sequence_at_or_before(0, u32::MAX));
assert!(sequence_at_or_before(0, (1 << 31) - 1), "just inside half the space");
assert!(!sequence_at_or_before(0, 1 << 31), "half the space away is not before");
}
}

View file

@ -2983,6 +2983,324 @@ fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Res
Ok(())
}
/// `bytes` with the MessagePack fixstr `from` replaced by the fixstr `to`, which may be blank.
fn swap_fixstr(bytes: &[u8], from: &str, to: &str) -> Vec<u8> {
assert!(from.len() < 32 && to.len() < 32);
let mut needle = vec![0xa0 | from.len() as u8];
needle.extend_from_slice(from.as_bytes());
let at = bytes.windows(needle.len()).position(|window| window == needle).expect("marker is in the frame");
let mut out = bytes[..at].to_vec();
out.push(0xa0 | to.len() as u8);
out.extend_from_slice(to.as_bytes());
out.extend_from_slice(&bytes[at + needle.len()..]);
out
}
/// A `schemaId` or `recordKey` that is present but empty or only whitespace names nothing: the write is refused
/// `message_invalid` like a frame that does not read (field "message", the write's id), never reaches the store,
/// and the session goes on (write-receipt.md, rule 5; vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`).
#[test]
fn a_write_whose_schema_id_or_record_key_names_nothing_is_refused_and_never_handed_to_the_store() -> Result<()> {
let blanks = ["", " ", "\t \n"];
let mut connection_id = 300;
for blank in blanks {
for blank_field in ["schemaId", "recordKey"] {
for operation in ["put", "delete"] {
connection_id += 1;
let (schema_id, record_key) = match blank_field {
"schemaId" => (blank, "note:1"),
_ => ("test.raw.v1", blank),
};
// The encoder refuses a blank field, so encode a marker and swap the blank into the bytes.
let bad = if operation == "put" {
let mut record = document("zz-key-zz", vec![1, 2, 3]);
record.schema_id = "zz-schema-zz".into();
encode_cultnet_message_to_vec(
&CultNetMessage::DocumentPutRaw { message_id: "bad-1".into(), document: record },
CultNetWireContract::CultNetSchemaV0,
)?
} else {
encode_cultnet_message_to_vec(
&CultNetMessage::DocumentDelete {
message_id: "bad-1".into(),
schema_id: "zz-schema-zz".into(),
record_key: "zz-key-zz".into(),
},
CultNetWireContract::CultNetSchemaV0,
)?
};
let bad = swap_fixstr(&bad, "zz-schema-zz", schema_id);
let bad = swap_fixstr(&bad, "zz-key-zz", record_key);
let label = format!("{operation} with {blank_field} {blank:?}");
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = client(server.local_addr()?, connection_id)?;
connect(&mut server, &mut [&mut peer])?;
send_reliable(&mut peer, &receipt_hello())?;
peer.send_reliable("schema", bad)?;
send_reliable(&mut peer, &put("good-1", "note:2", vec![1]))?;
let answers = answers_after(&mut server, &mut peer, 2)?;
assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into())), "{label}");
assert_eq!(error_text_and_field(&answers[0]).1, Some("message".into()), "{label}");
assert_eq!(answers[1], admitted("good-1", "note:2"), "{label}: the session goes on");
let state = sink.0.lock().unwrap();
assert_eq!(state.authorized, vec!["good-1".to_string()], "{label}: the store was never asked about the blank write");
assert_eq!(state.receipts.len(), 1, "{label}: only the good write was applied");
}
}
}
Ok(())
}
/// The sequenced-channel refusal belongs to the frame that arrived sequenced. A reliable-sequenced packet that
/// fills the gap an earlier ordered packet was held behind delivers both frames in one go: the sequenced write is
/// refused, the ordered write it released is admitted.
#[test]
fn a_sequenced_packet_releasing_a_held_ordered_write_refuses_only_its_own() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = RawPeer::connect(&mut server, 250)?;
peer.send(&receipt_hello())?;
server.poll_once()?;
let mut packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
peer.session.send_many(
"schema",
payload,
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None },
Some(1200),
)
};
let sequenced = packet_for(&mut peer, "s1", false, true)?;
let ordered = packet_for(&mut peer, "o1", true, false)?;
// The ordered write arrives first, one sequence ahead of the gap, and is held.
for packet in &ordered {
peer.send_packet(packet)?;
}
server.poll_once()?;
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the ordered write waits behind the gap");
for packet in &sequenced {
peer.send_packet(packet)?;
}
server.poll_once()?;
let mut answers = Vec::new();
for packet in peer.drain() {
for frame in peer.session.receive(&packet, 4)?.delivered {
answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?);
}
}
let refusals: Vec<_> = answers.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect();
assert_eq!(refusals.len(), 1, "only the sequenced write is refused: {answers:?}");
assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into())));
assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}");
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "and applied");
Ok(())
}
/// The refusal of a write on a sequenced channel does not depend on the packet being the newest. A reliable-sequenced
/// packet that arrives after a newer one on its channel is superseded: the session delivers no frame for it, yet
/// the packet was acknowledged and will not be resent, so the server answers it from the packet's own payload.
#[test]
fn a_superseded_sequenced_write_is_still_refused() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = RawPeer::connect(&mut server, 253)?;
peer.send(&receipt_hello())?;
server.poll_once()?;
let packet_for = |peer: &mut RawPeer, id: &str| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
peer.session.send_many(
"schema",
payload,
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
Some(1200),
)
};
let older = packet_for(&mut peer, "old")?;
let newer = packet_for(&mut peer, "new")?;
let mut refused_ids = Vec::new();
for packets in [&newer, &older] {
for packet in packets {
peer.send_packet(packet)?;
}
server.poll_once()?;
for packet in peer.drain() {
for frame in peer.session.receive(&packet, 4)?.delivered {
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
}
}
}
}
assert_eq!(refused_ids, vec!["new".to_string(), "old".to_string()], "the superseded write is answered too");
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied");
Ok(())
}
/// One answer per write (write-receipt.md, rule 1): a sequenced packet the server already answered is answered
/// once, however often the peer sends it again, and a packet too far ahead to be received draws nothing until it
/// is received (the peer resends it, and the answer comes then).
#[test]
fn a_repeated_or_far_ahead_sequenced_write_draws_one_answer_not_one_per_send() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = RawPeer::connect(&mut server, 256)?;
peer.send(&receipt_hello())?;
server.poll_once()?;
let packet_for = |peer: &mut RawPeer, id: &str| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
peer.session.send_many(
"schema",
payload,
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
Some(1200),
)
};
let refusals_of = |peer: &mut RawPeer| -> Result<Vec<String>> {
let mut ids = Vec::new();
for packet in peer.drain() {
for frame in peer.session.receive(&packet, 4)?.delivered {
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
}
}
}
Ok(ids)
};
// The same packet, sent three times (a resend whose acknowledgement was lost), is refused once.
let once = packet_for(&mut peer, "dup")?;
let mut refused = Vec::new();
for _ in 0..3 {
for packet in &once {
peer.send_packet(packet)?;
}
server.poll_once()?;
refused.extend(refusals_of(&mut peer)?);
}
assert_eq!(refused, vec!["dup".to_string()], "a repeat of an answered write is not answered again");
// A packet far past the receive window is not received: no answer yet, and none per resend.
let ahead = packet_for(&mut peer, "ahead")?;
let mut far = ahead[0].clone();
far.sequence = once[0].sequence + 5_000;
let mut refused = Vec::new();
for _ in 0..3 {
peer.send_packet(&far)?;
server.poll_once()?;
refused.extend(refusals_of(&mut peer)?);
}
assert_eq!(refused, Vec::<String>::new(), "an unreceived packet draws no answer");
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0);
Ok(())
}
/// A superseded sequenced packet is answered even when its arrival fills a gap and releases another write: the
/// frames that arrive with it do not stand in for its own.
#[test]
fn a_superseded_sequenced_write_is_refused_while_it_releases_a_held_ordered_write() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = RawPeer::connect(&mut server, 255)?;
peer.send(&receipt_hello())?;
server.poll_once()?;
let packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
peer.session.send_many(
"schema",
payload,
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None },
Some(1200),
)
};
let older = packet_for(&mut peer, "old", false, true)?;
let ordered = packet_for(&mut peer, "o1", true, false)?;
let newer = packet_for(&mut peer, "new", false, true)?;
let mut answers = Vec::new();
for packets in [&newer, &ordered, &older] {
for packet in packets {
peer.send_packet(packet)?;
}
server.poll_once()?;
for packet in peer.drain() {
for frame in peer.session.receive(&packet, 4)?.delivered {
answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?);
}
}
}
let mut refused: Vec<String> = answers
.iter()
.filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. }))
.map(|a| refusal_of(a).1.expect("a refusal names its write"))
.collect();
refused.sort();
assert_eq!(refused, vec!["new".to_string(), "old".to_string()], "{answers:?}");
assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}");
Ok(())
}
/// The refusal reaches a sequenced write however far apart its fragments' sequences sit: other traffic can take the
/// sequences between the first fragment and the last, further back than the duplicate test's receive window.
#[test]
fn a_sequenced_write_whose_fragments_span_more_than_the_receive_window_is_refused() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = RawPeer::connect(&mut server, 254)?;
peer.send(&receipt_hello())?;
server.poll_once()?;
let payload = encode_cultnet_message_to_vec(&put("far", "note:far", vec![1; 64]), CultNetWireContract::CultNetSchemaV0)?;
let mut packets = peer.session.send_many(
"schema",
payload,
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
Some(32),
)?;
let last = packets.pop().expect("the write is fragmented");
assert!(!packets.is_empty());
let mut next = last.sequence;
let mut refused_ids = Vec::new();
for packet in &packets {
peer.send_packet(packet)?;
server.poll_once()?;
}
// 5,000 reliable packets on another channel, in order, take the sequences between the fragments.
for count in 0..5_000 {
let mut filler = packets[0].clone();
filler.channel_id = "filler".into();
filler.sequenced = false;
filler.fragment_id = 0;
filler.fragment_index = 0;
filler.fragment_count = 0;
filler.payload = vec![0];
filler.sequence = next;
next += 1;
peer.send_packet(&filler)?;
server.poll_once()?;
if count % 100 == 99 {
// Read what the server sent, so the socket buffer never holds back the refusal.
for packet in peer.drain() {
peer.session.receive(&packet, 4)?;
}
}
}
let mut last = last;
last.sequence = next;
peer.send_packet(&last)?;
server.poll_once()?;
for packet in peer.drain() {
for frame in peer.session.receive(&packet, 4)?.delivered {
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
}
}
}
assert_eq!(refused_ids, vec!["far".to_string()]);
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied");
Ok(())
}
/// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured
/// from the refusal and not from the packet before it.
#[test]

View file

@ -24,7 +24,8 @@
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js"
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js",
"test:interop": "npm --prefix ../.. run build --workspace @gamecult/cultcache-ts --workspace @gamecult/cultnet-ts && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/write-receipt-interop.test.js"
},
"keywords": [
"cultmesh",

View file

@ -0,0 +1,434 @@
// The TypeScript write-receipt client (cultnet-ts WriteTracker and decoder, cultmesh-ts one-shot writer) against the two real
// receipt servers over loopback, as child processes on ephemeral ports:
//
// rust packages/cultmesh-rs/examples/receipt_interop_server.rs, the RUDP document server, reached over RUDP.
// csharp tests/GameCult.Networking.InteropPeer `receipt-serve`, CultNetDatabaseServer on Server (LiteNetLib). TypeScript has no
// LiteNetLib client, so a second child, `receipt-relay`, carries bytes between the test and the server (4-byte
// big-endian length, then the CultNet frame: CultNetPeer's own stream framing). It decodes and judges nothing.
//
// Every scenario runs against both targets; its outcome is read from the WriteTracker (admitted, refused, unconfirmed), and the
// server's own stdout facts say what was committed. Contract: contracts/cultnet/interop/write-receipt.md and its vectors.
//
// Needs cargo and dotnet; run it with `npm run test:interop` in packages/cultmesh-ts (CARGO and DOTNET override the commands;
// RECEIPT_INTEROP_PREBUILT=1 skips the two builds).
import assert from "node:assert/strict";
import { execFile, spawn, spawnSync, type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { Duplex } from "node:stream";
import { after, before, beforeEach, describe, test } from "node:test";
import { promisify } from "node:util";
import { encode } from "@msgpack/msgpack";
import { z } from "zod";
import { defineDocumentType } from "@gamecult/cultcache-ts";
import {
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
CultNetPeer,
WriteTracker,
defineCultNetDocumentBinding,
helloAdvertisesWriteReceipts,
resultWindowMs,
type CultNetErrorV1Message,
type CultNetHelloMessage,
type CultNetMessage,
type WriteSettlement,
} from "@gamecult/cultnet-ts";
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../../src/index";
const execFileAsync = promisify(execFile);
// dist-test/test/interop -> the repository root
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
const cargo = process.env.CARGO ?? "cargo";
const dotnet = process.env.DOTNET ?? "dotnet";
const rustManifestDir = join(repoRoot, "packages", "cultmesh-rs");
const csharpProject = join(repoRoot, "tests", "GameCult.Networking.InteropPeer", "GameCult.Networking.InteropPeer.csproj");
const csharpDll = join(repoRoot, "bin", "GameCult.Networking.InteropPeer", "Debug", "net10.0", "GameCult.Networking.InteropPeer.dll");
// Set when the server binaries were built beforehand (the builds themselves cannot start under a tight address-space cap).
const prebuilt = process.env.RECEIPT_INTEROP_PREBUILT === "1";
const vectors = JSON.parse(readFileSync(join(repoRoot, "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
const SCHEMA_ID = "receipt.interop_note.v1";
const UNREADABLE = "message_invalid: the message could not be read";
const sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
async function waitFor(predicate: () => boolean, what: string, ms = 5_000): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > ms) throw new Error(`timed out waiting for ${what}`);
await sleep(5);
}
}
// ---- the servers -------------------------------------------------------------------------------------------------------
interface Fact { event: string; recordKey?: string; messageId?: string; [key: string]: unknown }
/** A running server child and the JSON facts it prints on stdout. */
class ServerProcess {
readonly facts: Fact[] = [];
stderr = "";
port = 0;
constructor(readonly child: ChildProcess) {
let pending = "";
child.stdout!.on("data", (chunk: Buffer) => {
pending += chunk.toString("utf8");
for (let at = pending.indexOf("\n"); at >= 0; at = pending.indexOf("\n")) {
const line = pending.slice(0, at).trim();
pending = pending.slice(at + 1);
if (line) this.facts.push(JSON.parse(line) as Fact);
}
});
child.stderr!.on("data", (chunk: Buffer) => { this.stderr += chunk.toString("utf8"); });
}
/** How many times `event` was printed for `recordKey`. */
count(event: string, recordKey: string): number {
return this.facts.filter((fact) => fact.event === event && fact.recordKey === recordKey).length;
}
async stop(): Promise<void> {
this.child.stdin!.end();
await new Promise<void>((done) => {
const killer = setTimeout(() => this.child.kill("SIGKILL"), 5_000);
this.child.once("exit", () => { clearTimeout(killer); done(); });
if (this.child.exitCode !== null) done();
});
}
}
async function launch(command: string, args: string[]): Promise<ServerProcess> {
const server = new ServerProcess(spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }));
await waitFor(() => server.facts.some((fact) => fact.event === "listening") || server.child.exitCode !== null, "the server to listen", 60_000);
const listening = server.facts.find((fact) => fact.event === "listening");
if (!listening) throw new Error(`the server exited before listening:\n${server.stderr}`);
server.port = listening.port as number;
return server;
}
/** One connection to a server: the peer that speaks CultNet, and how to close it. */
interface Connection { peer: CultNetPeer; close(): void }
interface Target {
name: string;
/** Shard fields the server's database requires on a write, if any. */
shard?: { shardId: string; shardEpoch: number };
/** A document payload this target's store reads. */
payload(text: string): Uint8Array;
start(): Promise<ServerProcess>;
connect(server: ServerProcess): Promise<Connection>;
}
let nextConnectionId = 0x20206000;
const rust: Target = {
name: "rust",
payload: (text) => encode({ text }),
async start() {
if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" });
if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`);
const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`);
return launch(exe, []);
},
async connect(server) {
const peer = await CultMesh.createRudpPeer("receipt-interop", nextConnectionId++, { host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
return { peer, close: () => peer.close() };
},
};
// The C# server admits 30 connection requests per IP per minute (Server.MaxConnectionAttemptsPerMinute) and answers the rest with
// no hello. Every scenario wants a fresh session, so before each C# test the harness waits until the last minute holds at most
// CONNECTIONS_PER_MINUTE connections, leaving room for the two a test may open. The server's limit stays as it is.
const CONNECTIONS_PER_MINUTE = 24;
const connectedAt: number[] = [];
async function awaitConnectionBudget(): Promise<void> {
for (;;) {
const now = Date.now();
while (connectedAt.length > 0 && now - connectedAt[0] >= 61_000) connectedAt.shift();
if (connectedAt.length <= CONNECTIONS_PER_MINUTE) return;
await sleep(connectedAt[0] + 61_000 - now);
}
}
const csharp: Target = {
name: "csharp",
shard: { shardId: "receipt-interop-shard", shardEpoch: 1 },
payload: (text) => encode([text]),
async start() {
if (!prebuilt) await execFileAsync(dotnet, ["build", csharpProject, "-nologo", "-v", "q"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
return launch(dotnet, [csharpDll, "receipt-serve"]);
},
async connect(server) {
connectedAt.push(Date.now());
const relay = spawn(dotnet, [csharpDll, "receipt-relay", "--port", String(server.port)], { stdio: ["pipe", "pipe", "ignore"] });
const peer = new CultNetPeer(Duplex.from({ readable: relay.stdout!, writable: relay.stdin! }), { wireContract: "cultnet.schema.v0" });
return { peer, close: () => { relay.stdin!.end(); relay.kill(); } };
},
};
// ---- a client session: one connection, the frames it saw, one WriteTracker per write -----------------------------------------
class Session {
readonly frames: CultNetMessage[] = [];
readonly invalid: Error[] = [];
private readonly trackers = new Set<WriteTracker>();
private serverReceipts = false;
private constructor(readonly target: Target, private readonly connection: Connection) {
connection.peer.on("message", (message: CultNetMessage) => {
if (message.schemaVersion === "cultnet.hello.v0") this.serverReceipts = helloAdvertisesWriteReceipts(message);
this.frames.push(message);
for (const tracker of this.trackers) tracker.frame(message, Date.now());
});
connection.peer.on("invalidMessage", (error: Error) => this.invalid.push(error));
connection.peer.on("close", () => { for (const tracker of this.trackers) tracker.sessionEnd(Date.now()); });
}
static async open(target: Target, server: ServerProcess): Promise<Session> {
return new Session(target, await target.connect(server));
}
/** Declares the receipt capability and waits for the server's own hello. */
async hello(versions: readonly string[] = CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS): Promise<CultNetHelloMessage> {
this.connection.peer.sendHello({ schemaVersion: "cultnet.hello.v0", runtimeId: "receipt-interop", runtimeKind: "cultmesh-ts", supportedMessageVersions: [...versions] });
await waitFor(() => this.frames.some((frame) => frame.schemaVersion === "cultnet.hello.v0"), "the server's hello");
return this.frames.find((frame) => frame.schemaVersion === "cultnet.hello.v0") as CultNetHelloMessage;
}
put(messageId: string, recordKey: string, text: string, document: Record<string, unknown> = {}): CultNetMessage {
return this.shard({
schemaVersion: "cultnet.document_put_raw.v0",
messageId,
document: {
schemaId: SCHEMA_ID, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack",
payload: this.target.payload(text), ...document,
},
});
}
delete(messageId: string, recordKey: string): CultNetMessage {
return this.shard({ schemaVersion: "cultnet.document_delete.v0", messageId, schemaId: SCHEMA_ID, recordKey });
}
private shard(message: unknown): CultNetMessage {
return { ...(message as object), ...this.target.shard } as CultNetMessage;
}
/**
* Sends one write and settles it with its own tracker, windowed by what the server advertised (5000 ms against a receipt
* server, 500 ms otherwise). Only the tracker decides the outcome.
*/
async write(message: CultNetMessage): Promise<WriteSettlement> {
const messageId = (message as { messageId: string }).messageId;
const tracker = new WriteTracker({ windowMs: resultWindowMs(this.serverReceipts) });
this.trackers.add(tracker);
tracker.send(messageId, Date.now());
this.connection.peer.send(message);
for (;;) {
tracker.tick(Date.now());
const state = tracker.state(messageId)!;
if (state.outcome !== "pending") return state;
await sleep(Math.min(20, tracker.nextDeadlineMs(Date.now()) ?? 20));
}
}
/** Frames of `schemaVersion` that name `messageId`. */
answers(messageId: string, schemaVersion: string): CultNetMessage[] {
return this.frames.filter((frame) => frame.schemaVersion === schemaVersion && (frame as { messageId?: string }).messageId === messageId);
}
close(): void { this.connection.close(); }
}
function refusal(settlement: WriteSettlement): CultNetErrorV1Message {
assert.equal(settlement.outcome, "refused", JSON.stringify(settlement));
return (settlement as Extract<WriteSettlement, { outcome: "refused" }>).response as CultNetErrorV1Message;
}
// ---- the scenarios, run against each server -----------------------------------------------------------------------------------
for (const target of [rust, csharp]) {
describe(`write receipt, TypeScript client against the real ${target.name} server`, () => {
let server: ServerProcess;
let session: Session | undefined;
before(async () => { server = await target.start(); }, { timeout: 180_000 });
after(async () => { session?.close(); await server?.stop(); });
beforeEach(async () => { if (target === csharp) await awaitConnectionBudget(); }, { timeout: 90_000 });
/** A fresh receipt session (hello sent, the server's hello read) for a scenario. */
async function receiptSession(): Promise<Session> {
session?.close();
session = await Session.open(target, server);
const hello = await session.hello();
assert.ok(helloAdvertisesWriteReceipts(hello), "the server's hello lists both receipt frames");
return session;
}
test("hello listing both frames: the server answers with its own hello listing both, then a put is receipted after it", { timeout: 30_000 }, async () => {
const s = await receiptSession();
assert.equal(s.frames[0].schemaVersion, "cultnet.hello.v0");
const settlement = await s.write(s.put("hello-1", "hello:a", "one"));
assert.deepEqual(settlement, { outcome: "admitted" });
assert.deepEqual(s.frames.map((frame) => frame.schemaVersion), ["cultnet.hello.v0", "cultnet.write_admitted.v0"]);
});
test("a hello listing only one of the two frames does not opt in: no receipt, and the put is applied", { timeout: 30_000 }, async () => {
for (const listed of CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS) {
session?.close();
session = await Session.open(target, server);
session.hello([listed]).catch(() => undefined); // a server may stay silent to a hello that does not opt in
await sleep(300);
const settlement = await session.write(session.put(`half-${listed}`, `half:${listed}`, "one"));
assert.deepEqual(settlement, { outcome: "unconfirmed", reason: "window_elapsed" }, `listing only ${listed}`);
assert.ok(!session.frames.some((frame) => frame.schemaVersion === "cultnet.write_admitted.v0" || frame.schemaVersion === "cultnet.error.v1"));
await waitFor(() => server.count("applied", `half:${listed}`) === 1, "the put to be applied");
}
});
test("put admitted: the receipt echoes the write and the store has it once", { timeout: 30_000 }, async () => {
const s = await receiptSession();
const settlement = await s.write(s.put("put-1", "put:a", "one"));
assert.deepEqual(settlement, { outcome: "admitted" });
assert.deepEqual(s.answers("put-1", "cultnet.write_admitted.v0"), [
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "put-1", operation: "put", schemaId: SCHEMA_ID, recordKey: "put:a" },
]);
await waitFor(() => server.count("applied", "put:a") === 1, "the put to be applied");
});
// The Rust RUDP document server has no delete: its sink only accepts documents, and every delete on a receipt session is
// answered with a refusal (rudp_document_server.rs, deletes_on_a_receipt_session_are_refused_one_by_one). The spec
// (Server rules 1 to 3) has a delete admitted and receipted like a put.
test("delete admitted: the receipt says operation delete and the store drops the record", {
timeout: 30_000,
todo: target.name === "rust" ? "the Rust RUDP document server refuses every delete; there is no delete port in CultMeshRudpRawDocumentSink" : false,
}, async () => {
const s = await receiptSession();
assert.deepEqual(await s.write(s.put("del-put", "del:a", "one")), { outcome: "admitted" });
assert.deepEqual(await s.write(s.delete("del-1", "del:a")), { outcome: "admitted" });
assert.deepEqual(s.answers("del-1", "cultnet.write_admitted.v0"), [
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "del-1", operation: "delete", schemaId: SCHEMA_ID, recordKey: "del:a" },
]);
await waitFor(() => server.count("removed", "del:a") === 1, "the delete to be applied");
});
test("refused write: an error.v1 carrying the write's id, and nothing is applied", { timeout: 30_000 }, async () => {
const s = await receiptSession();
const response = refusal(await s.write(s.put("deny-1", "refuse:a", "one")));
assert.equal(response.schemaVersion, "cultnet.error.v1");
assert.equal(response.messageId, "deny-1");
assert.equal(response.code, "write_not_authorized");
// The session is kept: the next write on it is admitted.
assert.deepEqual(await s.write(s.put("deny-2", "deny:b", "one")), { outcome: "admitted" });
assert.equal(server.count("applied", "refuse:a"), 0);
});
test("resend of the same id and content on the same session: re-receipted, applied once", { timeout: 30_000 }, async () => {
const s = await receiptSession();
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
const receipts = s.answers("resend-1", "cultnet.write_admitted.v0");
assert.equal(receipts.length, 2);
assert.deepEqual(receipts[0], receipts[1]);
await sleep(200); // a second application would be printed by now
assert.equal(server.count("applied", "resend:a"), 1);
});
test("same id with different content: message_invalid naming the id, never a receipt, the first write untouched", { timeout: 30_000 }, async () => {
const s = await receiptSession();
assert.deepEqual(await s.write(s.put("clash-1", "clash:a", "one")), { outcome: "admitted" });
const response = refusal(await s.write(s.put("clash-1", "clash:a", "other")));
assert.equal(response.code, "message_invalid");
assert.equal(response.messageId, "clash-1");
assert.equal(response.details?.field, "messageId");
assert.equal(s.answers("clash-1", "cultnet.write_admitted.v0").length, 1, "no receipt for the changed write");
await sleep(200);
assert.equal(server.count("applied", "clash:a"), 1);
});
// Each case of the vectors' blankFields section: a document that names nothing is refused as an unreadable frame, with the
// write's id and the fixed text, and never applied; a document that is merely missing optional fields is admitted.
for (const blank of vectors.blankFields.cases as { name: string; set: Record<string, unknown>; outcome: "admitted" | "refused" }[]) {
test(`blank-field vector ${blank.name}: ${blank.outcome}`, { timeout: 30_000 }, async () => {
const s = await receiptSession();
const { payloadHex, ...fields } = blank.set;
// A case that sets recordKey replaces the harness key, so the store is read under the key the server actually saw.
const recordKey = typeof fields.recordKey === "string" ? fields.recordKey : `blank:${blank.name}`;
const message = s.put(`blank-${blank.name}`, recordKey, "one", fields);
const sent = (message as unknown as { document: Record<string, unknown> }).document;
for (const [key, value] of Object.entries(fields)) if (value === null) delete sent[key]; // nil means absent on the wire here
if (payloadHex === "") sent.payload = new Uint8Array();
const settlement = await s.write(message);
if (blank.outcome === "admitted") {
assert.deepEqual(settlement, { outcome: "admitted" });
await waitFor(() => server.count("applied", recordKey) === 1, "the put to be applied");
return;
}
const response = refusal(settlement);
assert.equal(response.code, "message_invalid");
assert.equal(response.error, UNREADABLE);
assert.equal(response.details?.field, "message");
assert.equal(response.messageId, `blank-${blank.name}`);
await sleep(200);
assert.equal(server.count("applied", recordKey), 0);
});
}
test("a delete that names nothing is refused as unreadable with its id", { timeout: 30_000 }, async () => {
const s = await receiptSession();
const message = s.delete("blank-del", "blank:del");
(message as unknown as { schemaId: string }).schemaId = "";
const response = refusal(await s.write(message));
assert.equal(response.code, "message_invalid");
assert.equal(response.error, UNREADABLE);
assert.equal(response.details?.field, "message");
assert.equal(response.messageId, "blank-del");
});
test("legacy client (no hello): no receipt frames, the write is applied, and its refusal is the old error.v0", { timeout: 30_000 }, async () => {
session?.close();
session = await Session.open(target, server);
assert.deepEqual(await session.write(session.put("legacy-1", "legacy:a", "one")), { outcome: "unconfirmed", reason: "window_elapsed" });
await waitFor(() => server.count("applied", "legacy:a") === 1, "the put to be applied");
const refused = (await session.write(session.put("legacy-2", "refuse:legacy", "one"))) as Extract<WriteSettlement, { outcome: "refused" }>;
assert.equal(refused.outcome, "refused");
assert.equal(refused.response.schemaVersion, "cultnet.error.v0");
assert.deepEqual(session.frames.map((frame) => frame.schemaVersion), ["cultnet.error.v0"]);
assert.equal(session.invalid.length, 0);
});
});
}
// ---- the one-shot writer, which is the real cultmesh-ts client path, against the Rust RUDP server ----------------------------
// (The C# server speaks LiteNetLib, not RUDP: no RUDP client path reaches it.)
describe("write receipt, cultmesh-ts publishRudpDocumentOnce against the real rust server", () => {
const note = defineDocumentType({ type: "receipt.interop_note", schemaId: SCHEMA_ID, schema: z.object({ text: z.string() }), name: "text" });
const binding = defineCultNetDocumentBinding({ definition: note });
let server: ServerProcess;
before(async () => { server = await rust.start(); }, { timeout: 180_000 });
after(async () => { await server?.stop(); });
const endpoint = () => ({ host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
test("writeReceipts: the put resolves admitted on the receipt, after the server applied it", { timeout: 30_000 }, async () => {
const outcome = await CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:a", { text: "one" }, { writeReceipts: true, messageId: "once-1" });
assert.equal(outcome, "admitted");
await waitFor(() => server.count("applied", "once:a") === 1, "the put to be applied");
});
test("writeReceipts: a refused put rejects with the server's error.v1 naming the put", { timeout: 30_000 }, async () => {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "refuse:once", { text: "one" }, { writeReceipts: true, messageId: "once-2" }),
(error: unknown) => {
assert.ok(error instanceof CultMeshRudpWriteRefusedError);
assert.equal(error.response.schemaVersion, "cultnet.error.v1");
assert.equal((error.response as CultNetErrorV1Message).messageId, "once-2");
return true;
},
);
});
test("without writeReceipts the server stays silent: the put is unconfirmed, not admitted, though the server applied it", { timeout: 30_000 }, async () => {
await assert.rejects(
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:legacy", { text: "one" }, { messageId: "once-3" }),
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
);
await waitFor(() => server.count("applied", "once:legacy") === 1, "the put to be applied");
});
});

View file

@ -287,18 +287,21 @@ test("a replaced RUDP generation drops operations queued behind an active handle
runtimeId: identity.serviceInstanceId,
connectionId,
sessionToken: "signed-provider-token",
operationTimeoutMs: 100,
operationTimeoutMs: 1, // expires long before the test ends: the settle-by-timeout path is always exercised
socketFactory: () => socket,
}).connect(identity, new AbortController().signal);
try {
// The handler is held open and the closed generation never answers, so these two settle only by the
// client's operation timeout or by connection.close(). Observe them from the start: whichever fires
// first is a legitimate end of the call, and this test asserts on server-side handler calls, not on it.
const first = connection.register(
{ identity, requestedLeaseDurationMs: 10_000 },
new AbortController().signal,
);
).then(() => undefined, () => undefined);
const second = connection.register(
{ identity, requestedLeaseDurationMs: 10_000 },
new AbortController().signal,
);
).then(() => undefined, () => undefined);
await waitFor(() => handlerCalls === 1, "first queued provider operation");
const replacement = new CultNetRudpSession({ connectionId });
@ -318,7 +321,7 @@ test("a replaced RUDP generation drops operations queued behind an active handle
assert.equal(handlerCalls, 1, "transport-queued work from the closed generation must be dropped");
connection.close();
await Promise.allSettled([first, second]);
await Promise.all([first, second]);
} finally {
connection.close();
server.close();

View file

@ -2,6 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor)
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
- 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
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by

View file

@ -39,6 +39,8 @@ from .cultmesh_contracts import (
from .framing import decode_frame, encode_frame, read_frame, write_frame
from .messages import (
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
CultNetMessage,
CultNetWitnessArtifactBundle,
compute_simulation_claim_hash,
@ -51,7 +53,9 @@ from .messages import (
error_message,
message_invalid_error,
unsupported_schema_version_error,
write_content_hash,
hello,
hello_advertises_write_receipts,
login,
login_success,
parse_message,
@ -176,6 +180,8 @@ __all__ = [
"verse_catalog_request",
"verse_from_wire",
"CULTNET_ERROR_CODES",
"CULTNET_ERROR_V1_CODES",
"CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS",
"CultNetMessage",
"CultNetWitnessArtifactBundle",
"CultNetDatabaseSubscription",
@ -266,6 +272,8 @@ __all__ = [
"message_invalid_error",
"unsupported_schema_version_error",
"hello",
"hello_advertises_write_receipts",
"write_content_hash",
"INTEROP_WIRE_CONTRACT",
"login",
"login_success",

View file

@ -6,6 +6,8 @@ from typing import Any
import msgpack
from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER
@dataclass(frozen=True)
class CultNetMessage:
@ -29,7 +31,11 @@ def parse_message(payload: bytes) -> CultNetMessage:
body = dict(decoded)
del body["schemaVersion"]
if schema_version == "cultnet.error.v0":
_validate_error_body(body)
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES)
elif schema_version == "cultnet.error.v1":
_validate_error_body(body, "cultnet.error.v1", CULTNET_ERROR_V1_CODES)
elif schema_version == "cultnet.write_admitted.v0":
_validate_write_admitted_body(body)
return CultNetMessage(schema_version=schema_version, body=body)
@ -47,6 +53,13 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
"message_invalid",
)
# cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus limit_reached.
CULTNET_ERROR_V1_CODES: tuple[str, ...] = (*CULTNET_ERROR_CODES, "limit_reached")
# The two frames a receipt-capable hello lists in supportedMessageVersions; a peer lists both or neither
# (contracts/cultnet/interop/write-receipt.md, "Capability: opting in").
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: tuple[str, ...] = ("cultnet.write_admitted.v0", "cultnet.error.v1")
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
@ -54,16 +67,80 @@ def _is_uint(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _validate_error_body(body: dict[str, Any]) -> None:
"""Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
expected = ("error", "routingHint", "code", "details")
def _is_name(value: Any) -> bool:
return isinstance(value, str) and value != ""
# write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
# Unicode 16.0, compared raw (never stripped). The tables are embedded (unicode_messageid_tables.py, generated by
# scripts/gen-unicode-messageid-tables.py), not unicodedata's, so the running Python's Unicode version changes no result;
# the other four runtimes embed the same generated tables.
def _in_ranges(ranges: tuple[int, ...], point: int) -> bool:
"""Whether [point] lies in the flattened, sorted (low, high) pairs."""
low, high = 0, len(ranges) // 2 - 1
while low <= high:
mid = (low + high) // 2
if point < ranges[2 * mid]:
high = mid - 1
elif point > ranges[2 * mid + 1]:
low = mid + 1
else:
return True
return False
def _is_admitted_message_id(value: Any) -> bool:
if not isinstance(value, str):
return False
has_letter_or_number = False
for ch in value:
point = ord(ch)
if _in_ranges(DEFAULT_IGNORABLE, point):
return False
has_letter_or_number = has_letter_or_number or _in_ranges(LETTER_OR_NUMBER, point)
return has_letter_or_number
def _validate_write_admitted_body(body: dict[str, Any]) -> None:
"""Refuse a cultnet.write_admitted.v0 body that is not exactly the five keys of its closed schema."""
expected = ("messageId", "operation", "schemaId", "recordKey")
if set(body) != set(expected):
raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}")
raise ValueError(f"cultnet.write_admitted.v0 must carry exactly {list(expected)}, got {sorted(body)}")
for key in ("messageId", "schemaId", "recordKey"):
if not _is_name(body[key]):
raise ValueError(f"cultnet.write_admitted.v0 {key} must be a non-empty string")
if not _is_admitted_message_id(body["messageId"]):
raise ValueError("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point")
if body["operation"] not in ("put", "delete"):
raise ValueError("cultnet.write_admitted.v0 operation must be put or delete")
def write_content_hash(operation: str, schema_id: str, record_key: str, payload: bytes | None) -> bytes:
"""SHA-256 of the MessagePack array [operation, schemaId, recordKey, payload] (write-receipt.md, rule 5).
payload is the raw document payload, packed as a MessagePack bin for a put and nil for a delete.
"""
return hashlib.sha256(msgpack.packb([operation, schema_id, record_key, payload], use_bin_type=True)).digest()
def hello_advertises_write_receipts(hello_message: CultNetMessage) -> bool:
"""True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable."""
listed = hello_message.body.get("supportedMessageVersions")
return isinstance(listed, list) and all(version in listed for version in CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS)
def _validate_error_body(body: dict[str, Any], version: str, codes: tuple[str, ...]) -> None:
"""Refuse a cultnet.error body that is not the full-key shape its closed schema requires."""
expected = ("error", "routingHint", "code", "details") + (("messageId",) if version == "cultnet.error.v1" else ())
if set(body) != set(expected):
raise ValueError(f"{version} must carry exactly {list(expected)}, got {sorted(body)}")
if not isinstance(body["error"], str) or not body["error"]:
raise ValueError("cultnet.error.v0 error must be a non-empty string")
raise ValueError(f"{version} error must be a non-empty string")
code = body["code"]
if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code")
if code is not None and (not isinstance(code, str) or code not in codes):
raise ValueError(f"{version} code {code!r} is not a recognized refusal code")
if "messageId" in body and body["messageId"] is not None and not _is_name(body["messageId"]):
raise ValueError(f"{version} messageId must be a non-empty string or null")
hint = body["routingHint"]
if hint is not None:
if (
@ -72,17 +149,17 @@ def _validate_error_body(body: dict[str, Any]) -> None:
or not isinstance(hint["reason"], str)
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
):
raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
raise ValueError(f"{version} routingHint must be null or {{shard, reason}}")
details = body["details"]
if details is not None:
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
raise ValueError(f"{version} details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
for key in ("field", "value"):
if details[key] is not None and not isinstance(details[key], str):
raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
raise ValueError(f"{version} details.{key} must be a string or null")
for key in ("asOf", "current"):
if details[key] is not None and not _is_uint(details[key]):
raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
raise ValueError(f"{version} details.{key} must be a non-negative integer or null")
def hello(
@ -92,17 +169,18 @@ def hello(
supported_schema_versions: list[str] | None = None,
supported_mutation_contracts: list[dict[str, Any]] | None = None,
transport_profiles: list[dict[str, Any]] | None = None,
supported_message_versions: list[str] | None = None,
) -> CultNetMessage:
return CultNetMessage(
"cultnet.hello.v0",
{
"runtimeId": runtime_id,
"supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"],
"supportedSchemaVersions": supported_schema_versions or [],
"supportedMutationContracts": supported_mutation_contracts or [],
"transportProfiles": transport_profiles or [],
},
)
body: dict[str, Any] = {
"runtimeId": runtime_id,
"supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"],
"supportedSchemaVersions": supported_schema_versions or [],
"supportedMutationContracts": supported_mutation_contracts or [],
"transportProfiles": transport_profiles or [],
}
if supported_message_versions is not None:
body["supportedMessageVersions"] = supported_message_versions
return CultNetMessage("cultnet.hello.v0", body)
def login(
@ -172,7 +250,7 @@ def error_message(
}
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
_validate_error_body(body)
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES)
return CultNetMessage("cultnet.error.v0", body)

View file

@ -0,0 +1,127 @@
# generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
# (low, high) pairs, flattened and sorted.
LETTER_OR_NUMBER: tuple[int, ...] = (
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
0x323AF,
)
DEFAULT_IGNORABLE: tuple[int, ...] = (
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
)

View file

@ -0,0 +1,310 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
from typing import Any
import msgpack
from cultnet_py import (
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
error_message,
hello,
hello_advertises_write_receipts,
parse_message,
write_content_hash,
)
# Decoder-side conformance with contracts/cultnet/interop/write-receipt-vectors.json: `contentHash`, `wire` and
# `schemaInvalid`. `dedupe` and `blankFields` are server obligations and `scenarios` is the client write tracker; `counts` is the file's own tally;
# this runtime has neither a receipt server nor a write client, so it replays none of them.
_INTEROP = Path(__file__).resolve().parents[3] / "contracts/cultnet/interop"
_VECTORS = json.loads((_INTEROP / "write-receipt-vectors.json").read_text(encoding="utf-8"))
_ERROR_V1_SCHEMA = json.loads((_INTEROP / "cultnet.error.v1.schema.json").read_text(encoding="utf-8"))
def _pack(wire: dict[str, Any]) -> bytes:
return msgpack.packb(wire, use_bin_type=True)
def _admitted(**override: Any) -> dict[str, Any]:
return {
"schemaVersion": "cultnet.write_admitted.v0",
"messageId": "put-1",
"operation": "put",
"schemaId": "note.v0",
"recordKey": "note:1",
**override,
}
def _refused(**override: Any) -> dict[str, Any]:
return {
"schemaVersion": "cultnet.error.v1",
"error": "write_not_authorized: no",
"routingHint": None,
"code": "write_not_authorized",
"details": None,
"messageId": "put-1",
**override,
}
class WriteReceiptVectorTests(unittest.TestCase):
def test_vector_sections_are_the_ones_this_file_accounts_for(self) -> None:
self.assertEqual(
set(_VECTORS) - {"provenance", "specification"},
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts", "admittedMessageId", "admittedMessageIdDuplicates"},
)
self.assertEqual(len(_VECTORS["contentHash"]["cases"]), 4)
self.assertEqual(len(_VECTORS["wire"]), 7)
self.assertEqual(len(_VECTORS["schemaInvalid"]), 7)
# The file declares how many of each it holds; a loader that drops entries disagrees with it.
counts = _VECTORS["counts"]
self.assertEqual(counts["wire"], len(_VECTORS["wire"]))
self.assertEqual(counts["schemaInvalid"], len(_VECTORS["schemaInvalid"]))
self.assertEqual(counts["contentHashCases"], len(_VECTORS["contentHash"]["cases"]))
self.assertEqual(counts["scenarioCases"], len(_VECTORS["scenarios"]["cases"]))
self.assertEqual(counts["admittedMessageIdCases"], len(_VECTORS["admittedMessageId"]["cases"]))
def test_admitted_message_id_vectors_are_refused_or_accepted_raw(self) -> None:
# `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
# compared raw (never trimmed). A lone surrogate is not valid UTF-8, so its vector carries the whole frame.
cases = _VECTORS["admittedMessageId"]["cases"]
failures = []
for case in cases:
raw = (
bytes.fromhex(case["wireHex"])
if "wireHex" in case
else _pack(_admitted(messageId="".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)))
)
try:
message = parse_message(raw)
except Exception: # noqa: BLE001 - any refusal counts
refused = True
else:
refused = False
wanted = "".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)
if message.body["messageId"] != wanted:
failures.append(f"{case['name']}: id not echoed raw")
if refused != (case["expect"] == "refuse"):
failures.append(f"{case['name']}: expected {case['expect']}")
self.assertEqual(failures, [])
def test_a_repeated_message_id_key_follows_the_last_key_wins_policy(self) -> None:
# Python decodes the frame to a dict, so it cannot see a duplicate: it judges the last key (admittedMessageIdDuplicates).
section = _VECTORS["admittedMessageIdDuplicates"]
self.assertEqual(_VECTORS["counts"]["admittedMessageIdDuplicateCases"], len(section["cases"]))
for case in section["cases"]:
raw = bytes.fromhex(case["wireHex"])
if case["lastKeyWins"] == "refuse":
with self.assertRaises(ValueError, msg=case["name"]):
parse_message(raw)
else:
self.assertEqual(parse_message(raw).body["messageId"], case["lastKeyWinsEcho"], case["name"])
def test_every_range_of_the_unicode_tables_is_pinned_at_both_ends(self) -> None:
# write-admitted-message-id-ranges.json is the generated, independent copy: for each letter-or-number range its first and
# last point are accepted and the points either side refused; for each Default_Ignorable range "a" beside its first and
# last point is refused and beside the points either side accepted. Deleting or narrowing any embedded range fails here.
ranges = json.loads((_INTEROP / "write-admitted-message-id-ranges.json").read_text(encoding="utf-8"))
self.assertGreater(len(ranges["letterOrNumber"]), 700)
self.assertEqual(len(ranges["defaultIgnorable"]), 17)
def accepted(text: str) -> bool:
try:
parse_message(_pack(_admitted(messageId=text)))
return True
except ValueError:
return False
failures = []
for low, high in ranges["letterOrNumber"]:
for point, want in ((low, True), (high, True), (low - 1, False), (high + 1, False)):
if accepted(chr(point)) != want:
failures.append(f"U+{point:04X} alone should be {'accepted' if want else 'refused'}")
for low, high in ranges["defaultIgnorable"]:
for point, want in ((low, False), (high, False), (low - 1, True), (high + 1, True)):
if accepted("a" + chr(point)) != want:
failures.append(f"a + U+{point:04X} should be {'accepted' if want else 'refused'}")
self.assertEqual(failures, [])
def test_content_hash_matches_every_vector(self) -> None:
for case in _VECTORS["contentHash"]["cases"]:
payload = None if case["payloadHex"] is None else bytes.fromhex(case["payloadHex"])
digest = write_content_hash(case["operation"], case["schemaId"], case["recordKey"], payload)
self.assertEqual(digest.hex(), case["sha256"], case["name"])
def test_wire_vectors_decode_to_their_fields_in_order_and_reencode_byte_identical(self) -> None:
for vector in _VECTORS["wire"]:
raw = bytes.fromhex(vector["messagePackHex"])
message = parse_message(raw)
self.assertEqual(message.to_wire(), vector["message"], vector["name"])
self.assertEqual(list(message.to_wire()), list(vector["message"]), vector["name"])
self.assertEqual(message.to_bytes(), raw, vector["name"])
def test_schema_invalid_vectors_are_refused(self) -> None:
for vector in _VECTORS["schemaInvalid"]:
with self.assertRaises(ValueError, msg=vector["name"]):
parse_message(bytes.fromhex(vector["messagePackHex"]))
def test_a_payload_that_is_not_bytes_changes_the_hash(self) -> None:
# The payload is a bin, not an str and not a re-parsed document.
as_bin = write_content_hash("put", "note.v0", "note:1", b"\x81\xa2id\xa11")
self.assertNotEqual(as_bin, write_content_hash("put", "note.v0", "note:1", None))
self.assertEqual(len(as_bin), 32)
class ErrorV1Tests(unittest.TestCase):
def test_code_set_equals_the_schema_enum_and_v0_is_unchanged(self) -> None:
schema_codes = [c for c in _ERROR_V1_SCHEMA["properties"]["code"]["enum"] if c is not None]
self.assertEqual(sorted(CULTNET_ERROR_V1_CODES), sorted(schema_codes))
self.assertNotIn("limit_reached", CULTNET_ERROR_CODES)
self.assertEqual(CULTNET_ERROR_V1_CODES[-1], "limit_reached")
def test_every_v1_code_decodes_and_an_unknown_one_does_not(self) -> None:
for code in CULTNET_ERROR_V1_CODES:
self.assertEqual(parse_message(_pack(_refused(code=code))).body["code"], code)
parse_message(_pack(_refused(code=None)))
with self.assertRaises(ValueError):
parse_message(_pack(_refused(code="invented")))
with self.assertRaises(ValueError):
parse_message(_pack(_refused(code=7)))
def test_limit_reached_is_v1_only(self) -> None:
v0 = {k: v for k, v in _refused(code="limit_reached", schemaVersion="cultnet.error.v0").items() if k != "messageId"}
with self.assertRaises(ValueError):
parse_message(_pack(v0))
with self.assertRaises(ValueError):
error_message("x", code="limit_reached")
def test_message_id_is_required_and_is_a_nonempty_string_or_null(self) -> None:
self.assertEqual(parse_message(_pack(_refused(messageId="w-é 1"))).body["messageId"], "w-é 1")
self.assertIsNone(parse_message(_pack(_refused(messageId=None))).body["messageId"])
omitted = {k: v for k, v in _refused().items() if k != "messageId"}
with self.assertRaises(ValueError):
parse_message(_pack(omitted))
for bad in ("", 5, b"put-1", ["put-1"], True):
with self.assertRaises(ValueError, msg=repr(bad)):
parse_message(_pack(_refused(messageId=bad)))
def test_v0_stays_closed_to_message_id(self) -> None:
v0 = _refused(schemaVersion="cultnet.error.v0", code="message_invalid")
with self.assertRaises(ValueError):
parse_message(_pack(v0))
del v0["messageId"]
self.assertEqual(parse_message(_pack(v0)).schema_version, "cultnet.error.v0")
def test_v1_is_closed_to_unknown_keys_and_keeps_the_shape_rules_of_v0(self) -> None:
for bad in (
_refused(extra=1),
_refused(error=""),
_refused(error=3),
_refused(routingHint={"shard": None}),
_refused(routingHint={"shard": 1, "reason": "r"}),
_refused(routingHint={"shard": None, "reason": 1}),
_refused(details={"field": None, "value": None, "asOf": None}),
_refused(details={"field": 1, "value": None, "asOf": None, "current": None}),
_refused(details={"field": None, "value": None, "asOf": -1, "current": None}),
_refused(details={"field": None, "value": None, "asOf": None, "current": True}),
):
with self.assertRaises(ValueError, msg=str(bad)):
parse_message(_pack(bad))
for good in (
_refused(routingHint={"shard": None, "reason": "r"}),
_refused(details={"field": "messageId", "value": "x", "asOf": 1, "current": 2}),
):
parse_message(_pack(good))
def test_the_refusal_reason_names_its_version(self) -> None:
with self.assertRaisesRegex(ValueError, "cultnet.error.v1 code"):
parse_message(_pack(_refused(code="invented")))
with self.assertRaisesRegex(ValueError, r"routingHint must be null or \{shard, reason\}"):
parse_message(_pack(_refused(routingHint={"shard": None})))
class RefusalTextTests(unittest.TestCase):
"""A refusal names its frame version and the rule it broke; a caller reads this text in a log."""
def assertRefused(self, wire: dict[str, Any], pattern: str) -> None:
with self.assertRaisesRegex(ValueError, pattern):
parse_message(_pack(wire))
def test_error_frames_name_their_own_version(self) -> None:
v0 = {k: v for k, v in _refused(schemaVersion="cultnet.error.v0").items() if k != "messageId"}
self.assertRefused({**v0, "code": "nope"}, r"^cultnet\.error\.v0 code")
self.assertRefused({**v0, "extra": 1}, r"^cultnet\.error\.v0 must carry exactly")
self.assertRefused(_refused(extra=1), r"^cultnet\.error\.v1 must carry exactly")
self.assertRefused(_refused(error=""), r"^cultnet\.error\.v1 error must be")
self.assertRefused(_refused(messageId=""), r"^cultnet\.error\.v1 messageId must be")
self.assertRefused(_refused(details={"field": None}), r"^cultnet\.error\.v1 details must be null or carry exactly")
self.assertRefused(
_refused(details={"field": 1, "value": None, "asOf": None, "current": None}), r"^cultnet\.error\.v1 details\.field must be"
)
self.assertRefused(
_refused(details={"field": None, "value": None, "asOf": -1, "current": None}), r"^cultnet\.error\.v1 details\.asOf must be"
)
with self.assertRaisesRegex(ValueError, r"^cultnet\.error\.v0 code"):
error_message("x", code="bogus")
def test_write_admitted_names_the_broken_rule(self) -> None:
self.assertRefused(_admitted(extra=1), r"^cultnet\.write_admitted\.v0 must carry exactly")
self.assertRefused(_admitted(recordKey=""), r"^cultnet\.write_admitted\.v0 recordKey must be a non-empty string")
self.assertRefused(_admitted(operation="upsert"), r"^cultnet\.write_admitted\.v0 operation must be put or delete$")
class WriteAdmittedTests(unittest.TestCase):
def test_both_operations_decode(self) -> None:
for operation in ("put", "delete"):
self.assertEqual(parse_message(_pack(_admitted(operation=operation))).body["operation"], operation)
def test_every_key_is_required_and_no_other_is_allowed(self) -> None:
for key in ("messageId", "operation", "schemaId", "recordKey"):
with self.assertRaises(ValueError, msg=key):
parse_message(_pack({k: v for k, v in _admitted().items() if k != key}))
for extra in ("error", "code", "details"):
with self.assertRaises(ValueError, msg=extra):
parse_message(_pack(_admitted(**{extra: "x"})))
def test_each_string_is_nonempty_and_a_string(self) -> None:
for key in ("messageId", "schemaId", "recordKey"):
for bad in ("", None, 3, b"x"):
with self.assertRaises(ValueError, msg=f"{key}={bad!r}"):
parse_message(_pack(_admitted(**{key: bad})))
def test_operation_is_put_or_delete(self) -> None:
for bad in ("upsert", "PUT", "", None, 1):
with self.assertRaises(ValueError, msg=repr(bad)):
parse_message(_pack(_admitted(operation=bad)))
def test_message_id_is_echoed_byte_for_byte(self) -> None:
self.assertEqual(parse_message(_pack(_admitted(messageId=" w é 1 "))).body["messageId"], " w é 1 ")
class HelloCapabilityTests(unittest.TestCase):
def test_capability_needs_both_frames(self) -> None:
both = list(CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS)
self.assertEqual(both, ["cultnet.write_admitted.v0", "cultnet.error.v1"])
self.assertTrue(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both)))
self.assertTrue(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=[*both, "x"])))
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both[:1])))
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both[1:])))
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=[])))
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r")))
def test_a_non_list_listing_is_not_a_capability(self) -> None:
wire = hello(runtime_id="r").to_wire()
wire["supportedMessageVersions"] = "cultnet.write_admitted.v0 cultnet.error.v1"
self.assertFalse(hello_advertises_write_receipts(parse_message(_pack(wire))))
def test_hello_writes_the_listing_only_when_given_and_round_trips(self) -> None:
self.assertNotIn("supportedMessageVersions", hello(runtime_id="r").body)
listed = hello(runtime_id="r", supported_message_versions=list(CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS))
self.assertEqual(parse_message(listed.to_bytes()), listed)
if __name__ == "__main__":
unittest.main()

View file

@ -680,7 +680,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
}
}
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => {
require_non_empty(message_id, "messageId")?;
require_admitted_message_id(message_id)?;
if operation != "put" && operation != "delete" {
return Err(anyhow!("operation must be put or delete"));
}
@ -934,6 +934,41 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> {
Ok(())
}
// write-receipt.md: a write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
// Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (never trimmed). The tables are embedded
// (unicode_messageid_tables.rs, generated by scripts/gen-unicode-messageid-tables.py), not std's, so no platform's Unicode
// version changes a result; the other four runtimes embed the same generated tables. Invalid UTF-8 never reaches this check:
// the MessagePack str decode refuses it.
use crate::unicode_messageid_tables::{DEFAULT_IGNORABLE, LETTER_OR_NUMBER};
fn in_ranges(ranges: &[(u32, u32)], point: u32) -> bool {
ranges.binary_search_by(|&(low, high)| {
if point < low {
std::cmp::Ordering::Greater
} else if point > high {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.is_ok()
}
fn require_admitted_message_id(value: &str) -> Result<()> {
let mut has_letter_or_number = false;
for ch in value.chars() {
let point = ch as u32;
if in_ranges(DEFAULT_IGNORABLE, point) {
return Err(anyhow!("CultNet field messageId must not hold a Default_Ignorable code point"));
}
has_letter_or_number |= in_ranges(LETTER_OR_NUMBER, point);
}
if !has_letter_or_number {
return Err(anyhow!("CultNet field messageId must contain a letter or number"));
}
Ok(())
}
fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> {
if value != expected {
return Err(anyhow!("{field} must be {expected}"));

View file

@ -3,6 +3,7 @@ mod contracts;
mod idunn_deployment_brake;
mod idunn_lifecycle_brake;
mod media_fec;
mod unicode_messageid_tables;
mod media_stream_contracts;
mod media_stream_wire;
mod provider_health_contracts;

View file

@ -1277,6 +1277,12 @@ impl CultNetRudpSession {
}
}
/// Whether `receive` has taken this reliable sequence: delivered, held, superseded or a duplicate. A packet
/// `receive` refused unremembered (too far ahead, or before the handshake) is not, and its sender resends it.
pub fn has_received(&self, sequence: u32) -> bool {
self.was_received(sequence)
}
/// The one duplicate test: true for a sequence in the window that was
/// received, for one below the window, and for one at or before the
/// watermark. The watermark starts at the handshake's seed, so a frame the

View file

@ -0,0 +1,147 @@
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
// (low, high) pairs, sorted.
pub const LETTER_OR_NUMBER: &[(u32, u32)] = &[
(0x30, 0x39), (0x41, 0x5A), (0x61, 0x7A), (0xAA, 0xAA), (0xB2, 0xB3), (0xB5, 0xB5), (0xB9, 0xBA), (0xBC, 0xBE),
(0xC0, 0xD6), (0xD8, 0xF6), (0xF8, 0x2C1), (0x2C6, 0x2D1), (0x2E0, 0x2E4), (0x2EC, 0x2EC), (0x2EE, 0x2EE),
(0x370, 0x374), (0x376, 0x377), (0x37A, 0x37D), (0x37F, 0x37F), (0x386, 0x386), (0x388, 0x38A), (0x38C, 0x38C),
(0x38E, 0x3A1), (0x3A3, 0x3F5), (0x3F7, 0x481), (0x48A, 0x52F), (0x531, 0x556), (0x559, 0x559), (0x560, 0x588),
(0x5D0, 0x5EA), (0x5EF, 0x5F2), (0x620, 0x64A), (0x660, 0x669), (0x66E, 0x66F), (0x671, 0x6D3), (0x6D5, 0x6D5),
(0x6E5, 0x6E6), (0x6EE, 0x6FC), (0x6FF, 0x6FF), (0x710, 0x710), (0x712, 0x72F), (0x74D, 0x7A5), (0x7B1, 0x7B1),
(0x7C0, 0x7EA), (0x7F4, 0x7F5), (0x7FA, 0x7FA), (0x800, 0x815), (0x81A, 0x81A), (0x824, 0x824), (0x828, 0x828),
(0x840, 0x858), (0x860, 0x86A), (0x870, 0x887), (0x889, 0x88E), (0x8A0, 0x8C9), (0x904, 0x939), (0x93D, 0x93D),
(0x950, 0x950), (0x958, 0x961), (0x966, 0x96F), (0x971, 0x980), (0x985, 0x98C), (0x98F, 0x990), (0x993, 0x9A8),
(0x9AA, 0x9B0), (0x9B2, 0x9B2), (0x9B6, 0x9B9), (0x9BD, 0x9BD), (0x9CE, 0x9CE), (0x9DC, 0x9DD), (0x9DF, 0x9E1),
(0x9E6, 0x9F1), (0x9F4, 0x9F9), (0x9FC, 0x9FC), (0xA05, 0xA0A), (0xA0F, 0xA10), (0xA13, 0xA28), (0xA2A, 0xA30),
(0xA32, 0xA33), (0xA35, 0xA36), (0xA38, 0xA39), (0xA59, 0xA5C), (0xA5E, 0xA5E), (0xA66, 0xA6F), (0xA72, 0xA74),
(0xA85, 0xA8D), (0xA8F, 0xA91), (0xA93, 0xAA8), (0xAAA, 0xAB0), (0xAB2, 0xAB3), (0xAB5, 0xAB9), (0xABD, 0xABD),
(0xAD0, 0xAD0), (0xAE0, 0xAE1), (0xAE6, 0xAEF), (0xAF9, 0xAF9), (0xB05, 0xB0C), (0xB0F, 0xB10), (0xB13, 0xB28),
(0xB2A, 0xB30), (0xB32, 0xB33), (0xB35, 0xB39), (0xB3D, 0xB3D), (0xB5C, 0xB5D), (0xB5F, 0xB61), (0xB66, 0xB6F),
(0xB71, 0xB77), (0xB83, 0xB83), (0xB85, 0xB8A), (0xB8E, 0xB90), (0xB92, 0xB95), (0xB99, 0xB9A), (0xB9C, 0xB9C),
(0xB9E, 0xB9F), (0xBA3, 0xBA4), (0xBA8, 0xBAA), (0xBAE, 0xBB9), (0xBD0, 0xBD0), (0xBE6, 0xBF2), (0xC05, 0xC0C),
(0xC0E, 0xC10), (0xC12, 0xC28), (0xC2A, 0xC39), (0xC3D, 0xC3D), (0xC58, 0xC5A), (0xC5D, 0xC5D), (0xC60, 0xC61),
(0xC66, 0xC6F), (0xC78, 0xC7E), (0xC80, 0xC80), (0xC85, 0xC8C), (0xC8E, 0xC90), (0xC92, 0xCA8), (0xCAA, 0xCB3),
(0xCB5, 0xCB9), (0xCBD, 0xCBD), (0xCDD, 0xCDE), (0xCE0, 0xCE1), (0xCE6, 0xCEF), (0xCF1, 0xCF2), (0xD04, 0xD0C),
(0xD0E, 0xD10), (0xD12, 0xD3A), (0xD3D, 0xD3D), (0xD4E, 0xD4E), (0xD54, 0xD56), (0xD58, 0xD61), (0xD66, 0xD78),
(0xD7A, 0xD7F), (0xD85, 0xD96), (0xD9A, 0xDB1), (0xDB3, 0xDBB), (0xDBD, 0xDBD), (0xDC0, 0xDC6), (0xDE6, 0xDEF),
(0xE01, 0xE30), (0xE32, 0xE33), (0xE40, 0xE46), (0xE50, 0xE59), (0xE81, 0xE82), (0xE84, 0xE84), (0xE86, 0xE8A),
(0xE8C, 0xEA3), (0xEA5, 0xEA5), (0xEA7, 0xEB0), (0xEB2, 0xEB3), (0xEBD, 0xEBD), (0xEC0, 0xEC4), (0xEC6, 0xEC6),
(0xED0, 0xED9), (0xEDC, 0xEDF), (0xF00, 0xF00), (0xF20, 0xF33), (0xF40, 0xF47), (0xF49, 0xF6C), (0xF88, 0xF8C),
(0x1000, 0x102A), (0x103F, 0x1049), (0x1050, 0x1055), (0x105A, 0x105D), (0x1061, 0x1061), (0x1065, 0x1066),
(0x106E, 0x1070), (0x1075, 0x1081), (0x108E, 0x108E), (0x1090, 0x1099), (0x10A0, 0x10C5), (0x10C7, 0x10C7),
(0x10CD, 0x10CD), (0x10D0, 0x10FA), (0x10FC, 0x1248), (0x124A, 0x124D), (0x1250, 0x1256), (0x1258, 0x1258),
(0x125A, 0x125D), (0x1260, 0x1288), (0x128A, 0x128D), (0x1290, 0x12B0), (0x12B2, 0x12B5), (0x12B8, 0x12BE),
(0x12C0, 0x12C0), (0x12C2, 0x12C5), (0x12C8, 0x12D6), (0x12D8, 0x1310), (0x1312, 0x1315), (0x1318, 0x135A),
(0x1369, 0x137C), (0x1380, 0x138F), (0x13A0, 0x13F5), (0x13F8, 0x13FD), (0x1401, 0x166C), (0x166F, 0x167F),
(0x1681, 0x169A), (0x16A0, 0x16EA), (0x16EE, 0x16F8), (0x1700, 0x1711), (0x171F, 0x1731), (0x1740, 0x1751),
(0x1760, 0x176C), (0x176E, 0x1770), (0x1780, 0x17B3), (0x17D7, 0x17D7), (0x17DC, 0x17DC), (0x17E0, 0x17E9),
(0x17F0, 0x17F9), (0x1810, 0x1819), (0x1820, 0x1878), (0x1880, 0x1884), (0x1887, 0x18A8), (0x18AA, 0x18AA),
(0x18B0, 0x18F5), (0x1900, 0x191E), (0x1946, 0x196D), (0x1970, 0x1974), (0x1980, 0x19AB), (0x19B0, 0x19C9),
(0x19D0, 0x19DA), (0x1A00, 0x1A16), (0x1A20, 0x1A54), (0x1A80, 0x1A89), (0x1A90, 0x1A99), (0x1AA7, 0x1AA7),
(0x1B05, 0x1B33), (0x1B45, 0x1B4C), (0x1B50, 0x1B59), (0x1B83, 0x1BA0), (0x1BAE, 0x1BE5), (0x1C00, 0x1C23),
(0x1C40, 0x1C49), (0x1C4D, 0x1C7D), (0x1C80, 0x1C8A), (0x1C90, 0x1CBA), (0x1CBD, 0x1CBF), (0x1CE9, 0x1CEC),
(0x1CEE, 0x1CF3), (0x1CF5, 0x1CF6), (0x1CFA, 0x1CFA), (0x1D00, 0x1DBF), (0x1E00, 0x1F15), (0x1F18, 0x1F1D),
(0x1F20, 0x1F45), (0x1F48, 0x1F4D), (0x1F50, 0x1F57), (0x1F59, 0x1F59), (0x1F5B, 0x1F5B), (0x1F5D, 0x1F5D),
(0x1F5F, 0x1F7D), (0x1F80, 0x1FB4), (0x1FB6, 0x1FBC), (0x1FBE, 0x1FBE), (0x1FC2, 0x1FC4), (0x1FC6, 0x1FCC),
(0x1FD0, 0x1FD3), (0x1FD6, 0x1FDB), (0x1FE0, 0x1FEC), (0x1FF2, 0x1FF4), (0x1FF6, 0x1FFC), (0x2070, 0x2071),
(0x2074, 0x2079), (0x207F, 0x2089), (0x2090, 0x209C), (0x2102, 0x2102), (0x2107, 0x2107), (0x210A, 0x2113),
(0x2115, 0x2115), (0x2119, 0x211D), (0x2124, 0x2124), (0x2126, 0x2126), (0x2128, 0x2128), (0x212A, 0x212D),
(0x212F, 0x2139), (0x213C, 0x213F), (0x2145, 0x2149), (0x214E, 0x214E), (0x2150, 0x2189), (0x2460, 0x249B),
(0x24EA, 0x24FF), (0x2776, 0x2793), (0x2C00, 0x2CE4), (0x2CEB, 0x2CEE), (0x2CF2, 0x2CF3), (0x2CFD, 0x2CFD),
(0x2D00, 0x2D25), (0x2D27, 0x2D27), (0x2D2D, 0x2D2D), (0x2D30, 0x2D67), (0x2D6F, 0x2D6F), (0x2D80, 0x2D96),
(0x2DA0, 0x2DA6), (0x2DA8, 0x2DAE), (0x2DB0, 0x2DB6), (0x2DB8, 0x2DBE), (0x2DC0, 0x2DC6), (0x2DC8, 0x2DCE),
(0x2DD0, 0x2DD6), (0x2DD8, 0x2DDE), (0x2E2F, 0x2E2F), (0x3005, 0x3007), (0x3021, 0x3029), (0x3031, 0x3035),
(0x3038, 0x303C), (0x3041, 0x3096), (0x309D, 0x309F), (0x30A1, 0x30FA), (0x30FC, 0x30FF), (0x3105, 0x312F),
(0x3131, 0x318E), (0x3192, 0x3195), (0x31A0, 0x31BF), (0x31F0, 0x31FF), (0x3220, 0x3229), (0x3248, 0x324F),
(0x3251, 0x325F), (0x3280, 0x3289), (0x32B1, 0x32BF), (0x3400, 0x4DBF), (0x4E00, 0xA48C), (0xA4D0, 0xA4FD),
(0xA500, 0xA60C), (0xA610, 0xA62B), (0xA640, 0xA66E), (0xA67F, 0xA69D), (0xA6A0, 0xA6EF), (0xA717, 0xA71F),
(0xA722, 0xA788), (0xA78B, 0xA7CD), (0xA7D0, 0xA7D1), (0xA7D3, 0xA7D3), (0xA7D5, 0xA7DC), (0xA7F2, 0xA801),
(0xA803, 0xA805), (0xA807, 0xA80A), (0xA80C, 0xA822), (0xA830, 0xA835), (0xA840, 0xA873), (0xA882, 0xA8B3),
(0xA8D0, 0xA8D9), (0xA8F2, 0xA8F7), (0xA8FB, 0xA8FB), (0xA8FD, 0xA8FE), (0xA900, 0xA925), (0xA930, 0xA946),
(0xA960, 0xA97C), (0xA984, 0xA9B2), (0xA9CF, 0xA9D9), (0xA9E0, 0xA9E4), (0xA9E6, 0xA9FE), (0xAA00, 0xAA28),
(0xAA40, 0xAA42), (0xAA44, 0xAA4B), (0xAA50, 0xAA59), (0xAA60, 0xAA76), (0xAA7A, 0xAA7A), (0xAA7E, 0xAAAF),
(0xAAB1, 0xAAB1), (0xAAB5, 0xAAB6), (0xAAB9, 0xAABD), (0xAAC0, 0xAAC0), (0xAAC2, 0xAAC2), (0xAADB, 0xAADD),
(0xAAE0, 0xAAEA), (0xAAF2, 0xAAF4), (0xAB01, 0xAB06), (0xAB09, 0xAB0E), (0xAB11, 0xAB16), (0xAB20, 0xAB26),
(0xAB28, 0xAB2E), (0xAB30, 0xAB5A), (0xAB5C, 0xAB69), (0xAB70, 0xABE2), (0xABF0, 0xABF9), (0xAC00, 0xD7A3),
(0xD7B0, 0xD7C6), (0xD7CB, 0xD7FB), (0xF900, 0xFA6D), (0xFA70, 0xFAD9), (0xFB00, 0xFB06), (0xFB13, 0xFB17),
(0xFB1D, 0xFB1D), (0xFB1F, 0xFB28), (0xFB2A, 0xFB36), (0xFB38, 0xFB3C), (0xFB3E, 0xFB3E), (0xFB40, 0xFB41),
(0xFB43, 0xFB44), (0xFB46, 0xFBB1), (0xFBD3, 0xFD3D), (0xFD50, 0xFD8F), (0xFD92, 0xFDC7), (0xFDF0, 0xFDFB),
(0xFE70, 0xFE74), (0xFE76, 0xFEFC), (0xFF10, 0xFF19), (0xFF21, 0xFF3A), (0xFF41, 0xFF5A), (0xFF66, 0xFFBE),
(0xFFC2, 0xFFC7), (0xFFCA, 0xFFCF), (0xFFD2, 0xFFD7), (0xFFDA, 0xFFDC), (0x10000, 0x1000B), (0x1000D, 0x10026),
(0x10028, 0x1003A), (0x1003C, 0x1003D), (0x1003F, 0x1004D), (0x10050, 0x1005D), (0x10080, 0x100FA),
(0x10107, 0x10133), (0x10140, 0x10178), (0x1018A, 0x1018B), (0x10280, 0x1029C), (0x102A0, 0x102D0),
(0x102E1, 0x102FB), (0x10300, 0x10323), (0x1032D, 0x1034A), (0x10350, 0x10375), (0x10380, 0x1039D),
(0x103A0, 0x103C3), (0x103C8, 0x103CF), (0x103D1, 0x103D5), (0x10400, 0x1049D), (0x104A0, 0x104A9),
(0x104B0, 0x104D3), (0x104D8, 0x104FB), (0x10500, 0x10527), (0x10530, 0x10563), (0x10570, 0x1057A),
(0x1057C, 0x1058A), (0x1058C, 0x10592), (0x10594, 0x10595), (0x10597, 0x105A1), (0x105A3, 0x105B1),
(0x105B3, 0x105B9), (0x105BB, 0x105BC), (0x105C0, 0x105F3), (0x10600, 0x10736), (0x10740, 0x10755),
(0x10760, 0x10767), (0x10780, 0x10785), (0x10787, 0x107B0), (0x107B2, 0x107BA), (0x10800, 0x10805),
(0x10808, 0x10808), (0x1080A, 0x10835), (0x10837, 0x10838), (0x1083C, 0x1083C), (0x1083F, 0x10855),
(0x10858, 0x10876), (0x10879, 0x1089E), (0x108A7, 0x108AF), (0x108E0, 0x108F2), (0x108F4, 0x108F5),
(0x108FB, 0x1091B), (0x10920, 0x10939), (0x10980, 0x109B7), (0x109BC, 0x109CF), (0x109D2, 0x10A00),
(0x10A10, 0x10A13), (0x10A15, 0x10A17), (0x10A19, 0x10A35), (0x10A40, 0x10A48), (0x10A60, 0x10A7E),
(0x10A80, 0x10A9F), (0x10AC0, 0x10AC7), (0x10AC9, 0x10AE4), (0x10AEB, 0x10AEF), (0x10B00, 0x10B35),
(0x10B40, 0x10B55), (0x10B58, 0x10B72), (0x10B78, 0x10B91), (0x10BA9, 0x10BAF), (0x10C00, 0x10C48),
(0x10C80, 0x10CB2), (0x10CC0, 0x10CF2), (0x10CFA, 0x10D23), (0x10D30, 0x10D39), (0x10D40, 0x10D65),
(0x10D6F, 0x10D85), (0x10E60, 0x10E7E), (0x10E80, 0x10EA9), (0x10EB0, 0x10EB1), (0x10EC2, 0x10EC4),
(0x10F00, 0x10F27), (0x10F30, 0x10F45), (0x10F51, 0x10F54), (0x10F70, 0x10F81), (0x10FB0, 0x10FCB),
(0x10FE0, 0x10FF6), (0x11003, 0x11037), (0x11052, 0x1106F), (0x11071, 0x11072), (0x11075, 0x11075),
(0x11083, 0x110AF), (0x110D0, 0x110E8), (0x110F0, 0x110F9), (0x11103, 0x11126), (0x11136, 0x1113F),
(0x11144, 0x11144), (0x11147, 0x11147), (0x11150, 0x11172), (0x11176, 0x11176), (0x11183, 0x111B2),
(0x111C1, 0x111C4), (0x111D0, 0x111DA), (0x111DC, 0x111DC), (0x111E1, 0x111F4), (0x11200, 0x11211),
(0x11213, 0x1122B), (0x1123F, 0x11240), (0x11280, 0x11286), (0x11288, 0x11288), (0x1128A, 0x1128D),
(0x1128F, 0x1129D), (0x1129F, 0x112A8), (0x112B0, 0x112DE), (0x112F0, 0x112F9), (0x11305, 0x1130C),
(0x1130F, 0x11310), (0x11313, 0x11328), (0x1132A, 0x11330), (0x11332, 0x11333), (0x11335, 0x11339),
(0x1133D, 0x1133D), (0x11350, 0x11350), (0x1135D, 0x11361), (0x11380, 0x11389), (0x1138B, 0x1138B),
(0x1138E, 0x1138E), (0x11390, 0x113B5), (0x113B7, 0x113B7), (0x113D1, 0x113D1), (0x113D3, 0x113D3),
(0x11400, 0x11434), (0x11447, 0x1144A), (0x11450, 0x11459), (0x1145F, 0x11461), (0x11480, 0x114AF),
(0x114C4, 0x114C5), (0x114C7, 0x114C7), (0x114D0, 0x114D9), (0x11580, 0x115AE), (0x115D8, 0x115DB),
(0x11600, 0x1162F), (0x11644, 0x11644), (0x11650, 0x11659), (0x11680, 0x116AA), (0x116B8, 0x116B8),
(0x116C0, 0x116C9), (0x116D0, 0x116E3), (0x11700, 0x1171A), (0x11730, 0x1173B), (0x11740, 0x11746),
(0x11800, 0x1182B), (0x118A0, 0x118F2), (0x118FF, 0x11906), (0x11909, 0x11909), (0x1190C, 0x11913),
(0x11915, 0x11916), (0x11918, 0x1192F), (0x1193F, 0x1193F), (0x11941, 0x11941), (0x11950, 0x11959),
(0x119A0, 0x119A7), (0x119AA, 0x119D0), (0x119E1, 0x119E1), (0x119E3, 0x119E3), (0x11A00, 0x11A00),
(0x11A0B, 0x11A32), (0x11A3A, 0x11A3A), (0x11A50, 0x11A50), (0x11A5C, 0x11A89), (0x11A9D, 0x11A9D),
(0x11AB0, 0x11AF8), (0x11BC0, 0x11BE0), (0x11BF0, 0x11BF9), (0x11C00, 0x11C08), (0x11C0A, 0x11C2E),
(0x11C40, 0x11C40), (0x11C50, 0x11C6C), (0x11C72, 0x11C8F), (0x11D00, 0x11D06), (0x11D08, 0x11D09),
(0x11D0B, 0x11D30), (0x11D46, 0x11D46), (0x11D50, 0x11D59), (0x11D60, 0x11D65), (0x11D67, 0x11D68),
(0x11D6A, 0x11D89), (0x11D98, 0x11D98), (0x11DA0, 0x11DA9), (0x11EE0, 0x11EF2), (0x11F02, 0x11F02),
(0x11F04, 0x11F10), (0x11F12, 0x11F33), (0x11F50, 0x11F59), (0x11FB0, 0x11FB0), (0x11FC0, 0x11FD4),
(0x12000, 0x12399), (0x12400, 0x1246E), (0x12480, 0x12543), (0x12F90, 0x12FF0), (0x13000, 0x1342F),
(0x13441, 0x13446), (0x13460, 0x143FA), (0x14400, 0x14646), (0x16100, 0x1611D), (0x16130, 0x16139),
(0x16800, 0x16A38), (0x16A40, 0x16A5E), (0x16A60, 0x16A69), (0x16A70, 0x16ABE), (0x16AC0, 0x16AC9),
(0x16AD0, 0x16AED), (0x16B00, 0x16B2F), (0x16B40, 0x16B43), (0x16B50, 0x16B59), (0x16B5B, 0x16B61),
(0x16B63, 0x16B77), (0x16B7D, 0x16B8F), (0x16D40, 0x16D6C), (0x16D70, 0x16D79), (0x16E40, 0x16E96),
(0x16F00, 0x16F4A), (0x16F50, 0x16F50), (0x16F93, 0x16F9F), (0x16FE0, 0x16FE1), (0x16FE3, 0x16FE3),
(0x17000, 0x187F7), (0x18800, 0x18CD5), (0x18CFF, 0x18D08), (0x1AFF0, 0x1AFF3), (0x1AFF5, 0x1AFFB),
(0x1AFFD, 0x1AFFE), (0x1B000, 0x1B122), (0x1B132, 0x1B132), (0x1B150, 0x1B152), (0x1B155, 0x1B155),
(0x1B164, 0x1B167), (0x1B170, 0x1B2FB), (0x1BC00, 0x1BC6A), (0x1BC70, 0x1BC7C), (0x1BC80, 0x1BC88),
(0x1BC90, 0x1BC99), (0x1CCF0, 0x1CCF9), (0x1D2C0, 0x1D2D3), (0x1D2E0, 0x1D2F3), (0x1D360, 0x1D378),
(0x1D400, 0x1D454), (0x1D456, 0x1D49C), (0x1D49E, 0x1D49F), (0x1D4A2, 0x1D4A2), (0x1D4A5, 0x1D4A6),
(0x1D4A9, 0x1D4AC), (0x1D4AE, 0x1D4B9), (0x1D4BB, 0x1D4BB), (0x1D4BD, 0x1D4C3), (0x1D4C5, 0x1D505),
(0x1D507, 0x1D50A), (0x1D50D, 0x1D514), (0x1D516, 0x1D51C), (0x1D51E, 0x1D539), (0x1D53B, 0x1D53E),
(0x1D540, 0x1D544), (0x1D546, 0x1D546), (0x1D54A, 0x1D550), (0x1D552, 0x1D6A5), (0x1D6A8, 0x1D6C0),
(0x1D6C2, 0x1D6DA), (0x1D6DC, 0x1D6FA), (0x1D6FC, 0x1D714), (0x1D716, 0x1D734), (0x1D736, 0x1D74E),
(0x1D750, 0x1D76E), (0x1D770, 0x1D788), (0x1D78A, 0x1D7A8), (0x1D7AA, 0x1D7C2), (0x1D7C4, 0x1D7CB),
(0x1D7CE, 0x1D7FF), (0x1DF00, 0x1DF1E), (0x1DF25, 0x1DF2A), (0x1E030, 0x1E06D), (0x1E100, 0x1E12C),
(0x1E137, 0x1E13D), (0x1E140, 0x1E149), (0x1E14E, 0x1E14E), (0x1E290, 0x1E2AD), (0x1E2C0, 0x1E2EB),
(0x1E2F0, 0x1E2F9), (0x1E4D0, 0x1E4EB), (0x1E4F0, 0x1E4F9), (0x1E5D0, 0x1E5ED), (0x1E5F0, 0x1E5FA),
(0x1E7E0, 0x1E7E6), (0x1E7E8, 0x1E7EB), (0x1E7ED, 0x1E7EE), (0x1E7F0, 0x1E7FE), (0x1E800, 0x1E8C4),
(0x1E8C7, 0x1E8CF), (0x1E900, 0x1E943), (0x1E94B, 0x1E94B), (0x1E950, 0x1E959), (0x1EC71, 0x1ECAB),
(0x1ECAD, 0x1ECAF), (0x1ECB1, 0x1ECB4), (0x1ED01, 0x1ED2D), (0x1ED2F, 0x1ED3D), (0x1EE00, 0x1EE03),
(0x1EE05, 0x1EE1F), (0x1EE21, 0x1EE22), (0x1EE24, 0x1EE24), (0x1EE27, 0x1EE27), (0x1EE29, 0x1EE32),
(0x1EE34, 0x1EE37), (0x1EE39, 0x1EE39), (0x1EE3B, 0x1EE3B), (0x1EE42, 0x1EE42), (0x1EE47, 0x1EE47),
(0x1EE49, 0x1EE49), (0x1EE4B, 0x1EE4B), (0x1EE4D, 0x1EE4F), (0x1EE51, 0x1EE52), (0x1EE54, 0x1EE54),
(0x1EE57, 0x1EE57), (0x1EE59, 0x1EE59), (0x1EE5B, 0x1EE5B), (0x1EE5D, 0x1EE5D), (0x1EE5F, 0x1EE5F),
(0x1EE61, 0x1EE62), (0x1EE64, 0x1EE64), (0x1EE67, 0x1EE6A), (0x1EE6C, 0x1EE72), (0x1EE74, 0x1EE77),
(0x1EE79, 0x1EE7C), (0x1EE7E, 0x1EE7E), (0x1EE80, 0x1EE89), (0x1EE8B, 0x1EE9B), (0x1EEA1, 0x1EEA3),
(0x1EEA5, 0x1EEA9), (0x1EEAB, 0x1EEBB), (0x1F100, 0x1F10C), (0x1FBF0, 0x1FBF9), (0x20000, 0x2A6DF),
(0x2A700, 0x2B739), (0x2B740, 0x2B81D), (0x2B820, 0x2CEA1), (0x2CEB0, 0x2EBE0), (0x2EBF0, 0x2EE5D),
(0x2F800, 0x2FA1D), (0x30000, 0x3134A), (0x31350, 0x323AF),
];
pub const DEFAULT_IGNORABLE: &[(u32, u32)] = &[
(0xAD, 0xAD), (0x34F, 0x34F), (0x61C, 0x61C), (0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F),
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
];

View file

@ -153,3 +153,103 @@ fn error_v0_refuses_the_v1_only_limit_reached_code() {
as_v0[at + 15] = b'0';
assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err());
}
/// Vectors `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code
/// point, compared raw (never trimmed). The frame is the spec's `write_admitted_put` bytes with the id swapped in; a
/// case with `wireHex` (the lone surrogate, invalid UTF-8) supplies the whole frame.
#[test]
fn write_admitted_message_id_vectors_are_refused_or_accepted_raw() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let cases = vectors["admittedMessageId"]["cases"].as_array().unwrap();
assert_eq!(vectors["counts"]["admittedMessageIdCases"].as_u64().unwrap() as usize, cases.len());
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
let mut failures = Vec::new();
for case in cases {
let name = case["name"].as_str().unwrap();
let id: String = case["codePoints"]
.as_array()
.unwrap()
.iter()
.filter_map(|cp| char::from_u32(u32::from_str_radix(cp.as_str().unwrap(), 16).unwrap()))
.collect::<String>()
.repeat(case["repeat"].as_u64().unwrap_or(1) as usize);
let frame = match case["wireHex"].as_str() {
Some(hex) => unhex(hex),
None => {
let len = id.len();
let header: Vec<u8> = if len < 32 {
vec![0xA0 | len as u8]
} else if len < 256 {
vec![0xD9, len as u8]
} else {
vec![0xDA, (len >> 8) as u8, len as u8]
};
[&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat()
}
};
let decoded = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0);
match (case["expect"].as_str().unwrap(), decoded) {
("refuse", Ok(_)) => failures.push(format!("{name}: expected refuse")),
("accept", Err(error)) => failures.push(format!("{name}: expected accept, got {error}")),
("accept", Ok(CultNetMessage::WriteAdmitted { message_id, .. })) if message_id != id => {
failures.push(format!("{name}: id not echoed raw"))
}
_ => {}
}
}
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
}
/// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
#[test]
fn every_range_of_the_unicode_tables_is_pinned_at_both_ends() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-admitted-message-id-ranges.json");
let ranges: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let letters = ranges["letterOrNumber"].as_array().unwrap();
let ignorable = ranges["defaultIgnorable"].as_array().unwrap();
assert!(letters.len() > 700);
assert_eq!(ignorable.len(), 17);
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
let accepted = |id: &str| {
let header = vec![0xA0 | id.len() as u8];
let frame = [&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat();
decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).is_ok()
};
let point = |value: i64| char::from_u32(value as u32).unwrap().to_string();
let mut failures = Vec::new();
for range in letters {
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
for (at, want) in [(low, true), (high, true), (low - 1, false), (high + 1, false)] {
if accepted(&point(at)) != want {
failures.push(format!("U+{at:X} alone should be {}", if want { "accepted" } else { "refused" }));
}
}
}
for range in ignorable {
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
for (at, want) in [(low, false), (high, false), (low - 1, true), (high + 1, true)] {
if accepted(&format!("a{}", point(at))) != want {
failures.push(format!("a + U+{at:X} should be {}", if want { "accepted" } else { "refused" }));
}
}
}
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
}
/// Vectors `admittedMessageIdDuplicates`: a repeated messageId key is refused whatever the values. This pins the outcome, not the
/// cause (today serde's `duplicate field messageId` in the typed decode of write_admitted).
#[test]
fn a_repeated_message_id_key_is_refused_whatever_the_values() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let cases = vectors["admittedMessageIdDuplicates"]["cases"].as_array().unwrap();
assert_eq!(vectors["counts"]["admittedMessageIdDuplicateCases"].as_u64().unwrap() as usize, cases.len());
for case in cases {
assert_eq!(case["seesDuplicate"], "refuse");
let decoded = decode_cultnet_message_from_slice(&unhex(case["wireHex"].as_str().unwrap()), CultNetWireContract::CultNetSchemaV0);
assert!(decoded.is_err(), "{}: a repeated messageId key must be refused", case["name"]);
}
}

View file

@ -10,6 +10,7 @@ import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
import { DEFAULT_IGNORABLE, LETTER_OR_NUMBER } from "./unicode-messageid-tables";
import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json";
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
@ -950,6 +951,10 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
return input as CultNetDatabaseChangeRawMessage;
}
if (schemaVersion === "cultnet.write_admitted.v0" && !isValidAdmittedMessageId((input as { messageId?: unknown }).messageId)) {
throw new Error("Validation failed for cultnet.write_admitted.v0: /messageId: must contain a letter or number and no Default_Ignorable code point");
}
if (!validator(input)) {
const keys = Object.keys(input as Record<string, unknown>).join(",");
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
@ -1460,6 +1465,45 @@ function validateRawDocumentRecord(
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
}
/**
* A write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
* Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (write-receipt.md; the id is echoed byte for byte, so nothing
* is trimmed). The tables are embedded (unicode-messageid-tables.ts, generated by scripts/gen-unicode-messageid-tables.py), not
* the regex engine's, so the running Node's Unicode version changes no result; the other four runtimes embed the same tables.
*/
function isValidAdmittedMessageId(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
let hasLetterOrNumber = false;
for (const ch of value) {
const point = ch.codePointAt(0) as number;
// The decoded string must be well-formed Unicode: @msgpack/msgpack turns invalid UTF-8 (ED A0 80) into a lone surrogate.
if ((point >= 0xd800 && point <= 0xdfff) || inRanges(DEFAULT_IGNORABLE, point)) {
return false;
}
hasLetterOrNumber = hasLetterOrNumber || inRanges(LETTER_OR_NUMBER, point);
}
return hasLetterOrNumber;
}
/** Whether [point] lies in the flattened, sorted [low, high] pairs. */
function inRanges(ranges: readonly number[], point: number): boolean {
let low = 0;
let high = ranges.length / 2 - 1;
while (low <= high) {
const mid = (low + high) >> 1;
if (point < ranges[2 * mid]) {
high = mid - 1;
} else if (point > ranges[2 * mid + 1]) {
low = mid + 1;
} else {
return true;
}
}
return false;
}
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
function isBlank(value: string): boolean {
return /^\p{White_Space}*$/u.test(value);

View file

@ -0,0 +1,126 @@
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
// [low, high] pairs, sorted.
export const LETTER_OR_NUMBER: readonly number[] = [
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F,
0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081, 0x108E,
0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, 0x124A,
0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2,
0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318,
0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681,
0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C, 0x176E,
0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819, 0x1820,
0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D, 0x1970,
0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89, 0x1A90,
0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, 0x1C00,
0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE,
0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48,
0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6,
0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2,
0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107,
0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212A,
0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B, 0x24EA,
0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, 0x2D27,
0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE, 0x2DB0,
0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F, 0x3005,
0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA, 0x30FC,
0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229, 0x3248,
0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500,
0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B,
0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A, 0xA80C,
0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, 0xA8FD,
0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6,
0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E,
0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0,
0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30,
0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900,
0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36, 0xFB38,
0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F, 0xFD92,
0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, 0xFF66,
0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D, 0x10026,
0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107, 0x10133,
0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323,
0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1, 0x103D5,
0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563,
0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1,
0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760, 0x10767,
0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835,
0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF,
0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC, 0x109CF,
0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60, 0x10A7E,
0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40, 0x10B55,
0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2,
0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80, 0x10EA9,
0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81,
0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075,
0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144,
0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0, 0x111DA,
0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286,
0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9,
0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335, 0x11339,
0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E, 0x1138E,
0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447, 0x1144A,
0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0, 0x114D9,
0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680, 0x116AA,
0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740, 0x11746,
0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916,
0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0,
0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50,
0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00, 0x11C08,
0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09,
0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A, 0x11D89,
0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12, 0x11F33,
0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480, 0x12543,
0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100, 0x1611D,
0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9,
0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77,
0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50, 0x16F50,
0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08,
0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88,
0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400, 0x1D454,
0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9,
0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C,
0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5,
0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736, 0x1D74E,
0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF,
0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149,
0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9,
0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE,
0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB,
0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F,
0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39, 0x1EE39,
0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D, 0x1EE4F,
0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D, 0x1EE5D,
0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74, 0x1EE77,
0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5, 0x1EEA9,
0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D,
0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF,
];
export const DEFAULT_IGNORABLE: readonly number[] = [
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
];

View file

@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { decode } from "@msgpack/msgpack";
import {
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
@ -280,3 +281,59 @@ test("a blank messageId, record key or tag is exactly Unicode White_Space, not t
parseCultNetMessage(put({ tags: [named] }));
}
});
// `admittedMessageId` vectors: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
// compared raw (never trimmed). The D800 vector is the frame's bytes (wireHex), decoded by the msgpack library like the others.
test("write_admitted messageId: every admittedMessageId vector is refused or accepted raw", () => {
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
assert.equal(vectors.counts.admittedMessageIdCases, vectors.admittedMessageId.cases.length);
const failures: string[] = [];
for (const vector of vectors.admittedMessageId.cases as { name: string; codePoints: string[]; repeat?: number; wireHex?: string; expect: "refuse" | "accept" }[]) {
const id = vector.codePoints.map((cp) => String.fromCodePoint(parseInt(cp, 16))).join("").repeat(vector.repeat ?? 1);
let decodedId: string | undefined;
try {
const frame = vector.wireHex ? decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex"))) : admitted(id);
decodedId = (parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId;
} catch {
decodedId = undefined;
}
if ((decodedId === undefined) !== (vector.expect === "refuse")) failures.push(`${vector.name}: expected ${vector.expect}`);
else if (decodedId !== undefined && decodedId !== id) failures.push(`${vector.name}: id not echoed raw`);
}
assert.deepEqual(failures, []);
});
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
test("write_admitted messageId: every range of the Unicode 16.0 tables is pinned at both ends", () => {
const ranges = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-admitted-message-id-ranges.json"), "utf8"));
assert.ok(ranges.letterOrNumber.length > 700);
assert.equal(ranges.defaultIgnorable.length, 17);
const accepted = (id: string) => { try { parseCultNetMessage(admitted(id)); return true; } catch { return false; } };
const failures: string[] = [];
for (const [low, high] of ranges.letterOrNumber as [number, number][]) {
for (const [point, want] of [[low, true], [high, true], [low - 1, false], [high + 1, false]] as [number, boolean][]) {
if (accepted(String.fromCodePoint(point)) !== want) failures.push(`U+${point.toString(16)} alone should be ${want ? "accepted" : "refused"}`);
}
}
for (const [low, high] of ranges.defaultIgnorable as [number, number][]) {
for (const [point, want] of [[low, false], [high, false], [low - 1, true], [high + 1, true]] as [number, boolean][]) {
if (accepted("a" + String.fromCodePoint(point)) !== want) failures.push(`a + U+${point.toString(16)} should be ${want ? "accepted" : "refused"}`);
}
}
assert.deepEqual(failures, []);
});
// TypeScript decodes the frame to an object, so it cannot see a duplicate key: it judges the last one (admittedMessageIdDuplicates).
// This pins @msgpack/msgpack's last-key behaviour plus the id check on the decoded object, not a first-key or duplicate path in
// cultnet-ts source: there is none, so a refuse-on-first mutation in src would not be killed.
test("write_admitted messageId: a repeated key follows the last-key-wins policy", () => {
const vectors = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
const section = vectors.admittedMessageIdDuplicates;
assert.equal(vectors.counts.admittedMessageIdDuplicateCases, section.cases.length);
for (const vector of section.cases as { name: string; wireHex: string; lastKeyWins: "accept" | "refuse"; lastKeyWinsEcho?: string }[]) {
const frame = decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex")));
if (vector.lastKeyWins === "refuse") assert.throws(() => parseCultNetMessage(frame), /./s, vector.name);
else assert.equal((parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId, vector.lastKeyWinsEcho, vector.name);
}
});

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Generate the Unicode 16.0 tables of the write_admitted messageId rule for the five CultNet runtimes.
The rule (contracts/cultnet/interop/write-receipt.md): a messageId is valid iff it holds a letter (Lu Ll Lt Lm Lo) or a number
(Nd Nl No) and no Default_Ignorable_Code_Point, all as of Unicode 16.0. The tables are embedded in each runtime so that no
platform's own Unicode version can change a result. Bumping the version is one coordinated change: run this script on the new
UCD files, commit the five regenerated files and the vectors that pin the new version, together.
gen-unicode-messageid-tables.py DerivedGeneralCategory.txt DerivedCoreProperties.txt [--check]
The inputs are https://www.unicode.org/Public/16.0.0/ucd/extracted/DerivedGeneralCategory.txt and
https://www.unicode.org/Public/16.0.0/ucd/DerivedCoreProperties.txt. With --check nothing is written; the exit status is 1 if
any generated file differs from what these inputs produce (a stale or hand-edited table). Standard library only.
"""
import hashlib
import json
import re
import sys
from pathlib import Path
VERSION = "16.0.0"
ROOT = Path(__file__).resolve().parent.parent
LETTER_OR_NUMBER_CATEGORIES = {"Lu", "Ll", "Lt", "Lm", "Lo", "Nd", "Nl", "No"}
TARGETS = {
"ts": ROOT / "packages/cultnet-ts/src/unicode-messageid-tables.ts",
"py": ROOT / "packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py",
"kt": ROOT / "packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/UnicodeMessageIdTables.kt",
"cs": ROOT / "src/GameCult.Networking/CultNetUnicodeMessageIdTables.cs",
"rs": ROOT / "packages/cultnet-rs/src/unicode_messageid_tables.rs",
}
# The independent copy the runtimes' tests probe (first, last, and the code point either side of every range), so that deleting
# or narrowing any range of any embedded table fails a test.
RANGES_JSON = ROOT / "contracts/cultnet/interop/write-admitted-message-id-ranges.json"
def parse(path, wanted):
"""Ranges (low, high) of the lines `LOW[..HIGH] ; VALUE`, VALUE accepted by [wanted], merged and sorted."""
points = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
m = re.match(r"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\S+)", line)
if m and wanted(m.group(3)):
points.append((int(m.group(1), 16), int(m.group(2) or m.group(1), 16)))
points.sort()
merged = []
for low, high in points:
if merged and low <= merged[-1][1] + 1:
merged[-1] = (merged[-1][0], max(merged[-1][1], high))
else:
merged.append((low, high))
return merged
def flat(ranges):
return [value for pair in ranges for value in pair]
def wrapped(items, indent, render, width=118):
lines, current = [], indent
for item in items:
text = render(item) + ", "
if len(current) + len(text) > width:
lines.append(current.rstrip())
current = indent
current += text
lines.append(current.rstrip())
return "\n".join(lines)
def emit(target, letters, ignorable, header):
num = lambda v: f"0x{v:X}"
if target == "ts":
body = lambda name, r: f"export const {name}: readonly number[] = [\n{wrapped(flat(r), ' ', num)}\n];\n"
return f"// {header}\n\n// [low, high] pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "py":
body = lambda name, r: f"{name}: tuple[int, ...] = (\n{wrapped(flat(r), ' ', num)}\n)\n"
return f"# {header}\n\n# (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "kt":
body = lambda name, r: f"internal val {name}: IntArray = intArrayOf(\n{wrapped(flat(r), ' ', num)}\n)\n"
return f"// {header}\n\npackage org.gamecult.cultmesh\n\n// (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
if target == "cs":
body = lambda name, r: f" public static readonly int[] {name} =\n {{\n{wrapped(flat(r), ' ', num)}\n }};\n"
return f"// {header}\n\nnamespace GameCult.Networking\n{{\n internal static class CultNetUnicodeMessageIdTables\n {{\n // (low, high) pairs, flattened and sorted.\n{body('LetterOrNumber', letters)}\n{body('DefaultIgnorable', ignorable)} }}\n}}\n"
body = lambda name, r: f"pub const {name}: &[(u32, u32)] = &[\n{wrapped(r, ' ', lambda p: f'({num(p[0])}, {num(p[1])})')}\n];\n"
return f"// {header}\n\n// (low, high) pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
def main():
args = [a for a in sys.argv[1:] if a != "--check"]
check = "--check" in sys.argv[1:]
if len(args) != 2:
sys.exit(__doc__)
gc, dcp = args
for path, marker in ((gc, f"DerivedGeneralCategory-{VERSION}"), (dcp, f"DerivedCoreProperties-{VERSION}")):
if marker not in Path(path).read_text(encoding="utf-8").splitlines()[0]:
sys.exit(f"{path} is not {marker}.txt")
letters = parse(gc, lambda v: v in LETTER_OR_NUMBER_CATEGORIES)
ignorable = parse(dcp, lambda v: v == "Default_Ignorable_Code_Point")
digest = hashlib.sha256(b"".join(Path(p).read_bytes() for p in (gc, dcp))).hexdigest()[:16]
header = f"generated, Unicode {VERSION}, by scripts/gen-unicode-messageid-tables.py (inputs sha256 {digest}); do not edit"
stale = []
texts = {path: emit(target, letters, ignorable, header) for target, path in TARGETS.items()}
texts[RANGES_JSON] = json.dumps(
{"generated": header, "unicodeVersion": VERSION, "letterOrNumber": [list(r) for r in letters], "defaultIgnorable": [list(r) for r in ignorable]},
separators=(",", ":"),
) + "\n"
for path, text in texts.items():
if check:
if not path.exists() or path.read_text(encoding="utf-8") != text:
stale.append(str(path.relative_to(ROOT)))
else:
path.write_text(text, encoding="utf-8")
if stale:
sys.exit("stale or hand-edited: " + ", ".join(stale))
print(f"{'checked' if check else 'wrote'} {len(texts)} files: {len(letters)} letter-or-number ranges, {len(ignorable)} default-ignorable ranges")
if __name__ == "__main__":
main()

View file

@ -1,4 +1,5 @@
using System;
using System.Buffers;
using MessagePack;
namespace GameCult.Networking
@ -29,6 +30,7 @@ namespace GameCult.Networking
public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload);
return MessagePackSerializer.Deserialize<T>(payload, Options);
}
@ -98,4 +100,67 @@ namespace GameCult.Networking
[Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty;
}
}
/// <summary>
/// The write_admitted messageId rule (write-receipt.md): a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no
/// Default_Ignorable code point anywhere, as of Unicode 16.0, wire bytes that are valid UTF-8, compared raw (never trimmed).
/// The tables are embedded (CultNetUnicodeMessageIdTables.cs, generated by scripts/gen-unicode-messageid-tables.py), not
/// .NET's, so the running runtime's Unicode version changes no result; the other four runtimes embed the same tables.
/// </summary>
internal static class CultNetAdmittedMessageId
{
private static readonly System.Text.UTF8Encoding StrictUtf8 = new(false, true);
/// <summary>Throws unless the frame's one top-level <c>messageId</c> key is a valid write_admitted id (a repeated key is malformed); other keys are left to the serializer.</summary>
public static void Require(byte[] payload)
{
var reader = new MessagePackReader(payload);
var entries = reader.ReadMapHeader();
var seen = false;
for (var index = 0; index < entries; index++)
{
var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null;
if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String)
{
if (seen) throw new MessagePackSerializationException("cultnet.write_admitted.v0 carries messageId more than once.");
seen = true;
string id;
try { id = StrictUtf8.GetString(reader.ReadStringSequence()!.Value.ToArray()); }
catch (System.Text.DecoderFallbackException) { throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId is not valid UTF-8."); }
if (!IsValid(id)) throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point.");
continue;
}
if (key == null) reader.Skip();
reader.Skip();
}
}
public static bool IsValid(string id)
{
var hasLetterOrNumber = false;
for (var at = 0; at < id.Length; at += char.IsSurrogatePair(id, at) ? 2 : 1)
{
var point = char.IsSurrogatePair(id, at) ? char.ConvertToUtf32(id, at) : id[at];
if (InRanges(CultNetUnicodeMessageIdTables.DefaultIgnorable, point)) return false;
hasLetterOrNumber |= InRanges(CultNetUnicodeMessageIdTables.LetterOrNumber, point);
}
return hasLetterOrNumber;
}
private static bool InRanges(int[] ranges, int point)
{
int low = 0, high = ranges.Length / 2 - 1;
while (low <= high)
{
var mid = (low + high) >> 1;
if (point < ranges[2 * mid]) high = mid - 1;
else if (point > ranges[2 * mid + 1]) low = mid + 1;
else return true;
}
return false;
}
}
}

View file

@ -0,0 +1,144 @@
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
namespace GameCult.Networking
{
internal static class CultNetUnicodeMessageIdTables
{
// (low, high) pairs, flattened and sorted.
public static readonly int[] LetterOrNumber =
{
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0,
0xD6, 0xD8, 0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374,
0x376, 0x377, 0x37A, 0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3,
0x3F5, 0x3F7, 0x481, 0x48A, 0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2,
0x620, 0x64A, 0x660, 0x669, 0x66E, 0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF,
0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D, 0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA,
0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828, 0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889,
0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950, 0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980,
0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE,
0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC, 0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10,
0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66,
0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9,
0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13,
0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB6F,
0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10,
0xC12, 0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78,
0xC7E, 0xC80, 0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD,
0xCDD, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D,
0xD3D, 0xD4E, 0xD4E, 0xD54, 0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1,
0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0, 0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50,
0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86, 0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3,
0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0, 0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40,
0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D,
0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081, 0x108E, 0x108E, 0x1090, 0x1099, 0x10A0,
0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, 0x124A, 0x124D, 0x1250, 0x1256,
0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2, 0x12B5, 0x12B8,
0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318, 0x135A,
0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681,
0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810,
0x1819, 0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E,
0x1946, 0x196D, 0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20,
0x1A54, 0x1A80, 0x1A89, 0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59,
0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, 0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90,
0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF,
0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B,
0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4,
0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070,
0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107, 0x2107, 0x210A, 0x2113,
0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212A, 0x212D, 0x212F,
0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B, 0x24EA, 0x24FF,
0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, 0x2D27,
0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F,
0x2E2F, 0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F,
0x30A1, 0x30FA, 0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0,
0x31FF, 0x3220, 0x3229, 0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF,
0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0,
0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC,
0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A, 0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882,
0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, 0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946,
0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40,
0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1,
0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0, 0xAAEA, 0xAAF2,
0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30, 0xAB5A,
0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900,
0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50,
0xFD8F, 0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A,
0xFF41, 0xFF5A, 0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000,
0x1000B, 0x1000D, 0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D,
0x10080, 0x100FA, 0x10107, 0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0,
0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D,
0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1, 0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0,
0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A,
0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1, 0x105B3, 0x105B9, 0x105BB,
0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760, 0x10767, 0x10780, 0x10785,
0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835, 0x10837,
0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF,
0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48,
0x10A60, 0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00,
0x10B35, 0x10B40, 0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48,
0x10C80, 0x10CB2, 0x10CC0, 0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F,
0x10D85, 0x10E60, 0x10E7E, 0x10E80, 0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27,
0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003,
0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8,
0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144, 0x11147, 0x11147, 0x11150,
0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0, 0x111DA, 0x111DC, 0x111DC,
0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286, 0x11288,
0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9,
0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B,
0x1138E, 0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400,
0x11434, 0x11447, 0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5,
0x114C7, 0x114C7, 0x114D0, 0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644,
0x11644, 0x11650, 0x11659, 0x11680, 0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3,
0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740, 0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF,
0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F,
0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0, 0x119E1, 0x119E1, 0x119E3,
0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50, 0x11A5C, 0x11A89,
0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00, 0x11C08, 0x11C0A,
0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09,
0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10,
0x11F12, 0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400,
0x1246E, 0x12480, 0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA,
0x14400, 0x14646, 0x16100, 0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60,
0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43,
0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70,
0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50, 0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1,
0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5,
0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152, 0x1B155, 0x1B155,
0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88, 0x1BC90,
0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400, 0x1D454,
0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514,
0x1D516, 0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A,
0x1D550, 0x1D552, 0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714,
0x1D716, 0x1D734, 0x1D736, 0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA,
0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D,
0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0,
0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA,
0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7,
0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB, 0x1ECAD, 0x1ECAF,
0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F, 0x1EE21,
0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39, 0x1EE39,
0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B,
0x1EE5D, 0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C,
0x1EE72, 0x1EE74, 0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B,
0x1EEA1, 0x1EEA3, 0x1EEA5, 0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000,
0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D,
0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF,
};
public static readonly int[] DefaultIgnorable =
{
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F,
0x202A, 0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0,
0xFFF8, 0x1BCA0, 0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
};
}
}

View file

@ -25,7 +25,7 @@ static async Task<int> ProgramMainAsync(string[] args)
{
if (args.Length == 0)
{
throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve");
throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve | receipt-serve | receipt-relay");
}
var mode = args[0];
@ -53,6 +53,12 @@ static async Task<int> ProgramMainAsync(string[] args)
case "rudp-dial-message-once":
RudpDialMessageOnce(options);
return 0;
case "receipt-serve":
await GameCult.Networking.InteropPeer.ReceiptHarness.ServeAsync();
return 0;
case "receipt-relay":
await GameCult.Networking.InteropPeer.ReceiptHarness.RelayAsync(int.Parse(options["port"], CultureInfo.InvariantCulture));
return 0;
case "quic-realtime-serve":
await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options));
return 0;

View file

@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using LiteNetLib;
using R3;
// The real C# receipt server (CultNetDatabaseServer on Server) and the one transport shim the TypeScript client needs to reach it,
// for the cross-runtime write-receipt interop harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts).
// TypeScript has no LiteNetLib client, so the shim moves bytes and nothing else: each CultNet frame is a LiteNetLib reliable
// ordered payload, and on the pipe it is a 4-byte big-endian length followed by the frame.
namespace GameCult.Networking.InteropPeer
{
// CompatibleSchemaIds are the names the spec's blankFields vectors admit (U+200B, U+001C, and a name padded with NBSP and EM SPACE):
// a name is any string that is not blank, so the store must hold a record put under one of them.
[CultDocument("receipt.interop_note", ReceiptHarness.SchemaId, CompatibleSchemaIds = ["\u200b", "\u001c", "\u00a0a\u2003"])]
[MessagePack.MessagePackObject]
public sealed class ReceiptInteropNote
{
[MessagePack.Key(0)]
public string Text { get; set; } = string.Empty;
}
internal static class ReceiptHarness
{
public const string SchemaId = "receipt.interop_note.v1";
public const string ShardId = "receipt-interop-shard";
// A record key with this prefix is refused by the authority: how the harness asks for a refused write.
public const string RefusedPrefix = "refuse:";
private sealed class Authority : ICultNetRemoteWriteAuthority
{
public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) =>
request.RecordKey.ToString().StartsWith(RefusedPrefix, StringComparison.Ordinal)
? CultNetRemoteWriteDecision.Refuse("denied")
: CultNetRemoteWriteDecision.Grant();
}
private static void Emit(object fact)
{
lock (Console.Out) Console.Out.WriteLine(JsonSerializer.Serialize(fact));
}
// stdout: {"event":"listening","port":N} then {"event":"applied"|"removed","recordKey":"..."} per committed change; runs until stdin closes.
public static async Task ServeAsync()
{
var cache = new CultCache();
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [SchemaId])]
});
using var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 };
using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions
{
RemoteWriteAuthority = new Authority()
});
using var watch = cache.Watch<ReceiptInteropNote>().Subscribe(change =>
Emit(new { @event = change.Kind == CultCacheDocumentChangeKind.Removed ? "removed" : "applied", recordKey = change.Key.ToString() }));
server.Start();
Emit(new { @event = "listening", port = server.Port, shardId = ShardId, schemaId = SchemaId });
await Task.Run(() => Console.In.ReadToEnd());
}
// Connects to the server on `port` and relays frames between stdin/stdout and the connection; exits when either ends.
public static async Task RelayAsync(int port)
{
var listener = new EventBasedNetListener();
var connected = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var ended = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var output = Console.OpenStandardOutput();
var gate = new object();
listener.PeerConnectedEvent += _ => connected.TrySetResult(true);
listener.PeerDisconnectedEvent += (_, _) => ended.TrySetResult(true);
listener.NetworkReceiveEvent += (_, reader, _, _) =>
{
var frame = reader.GetRemainingBytes();
var header = new byte[] { (byte)(frame.Length >> 24), (byte)(frame.Length >> 16), (byte)(frame.Length >> 8), (byte)frame.Length };
lock (gate)
{
output.Write(header);
output.Write(frame);
output.Flush();
}
};
var manager = new NetManager(listener) { UnsyncedEvents = true };
manager.Start(0);
var peer = manager.Connect("127.0.0.1", port, ServerSecurityOptions.Development().ConnectionKey);
if (await Task.WhenAny(connected.Task, Task.Delay(TimeSpan.FromSeconds(10))) != connected.Task)
throw new InvalidOperationException("the relay did not connect");
var input = Console.OpenStandardInput();
var pump = Task.Run(() =>
{
var header = new byte[4];
while (ReadExactly(input, header))
{
var frame = new byte[(header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3]];
if (!ReadExactly(input, frame)) break;
peer.Send(frame, DeliveryMethod.ReliableOrdered);
}
});
await Task.WhenAny(pump, ended.Task);
manager.Stop();
}
private static bool ReadExactly(Stream stream, byte[] buffer)
{
var read = 0;
while (read < buffer.Length)
{
var count = stream.Read(buffer, read, buffer.Length - read);
if (count <= 0) return false;
read += count;
}
return true;
}
}
}

View file

@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// contracts/cultnet/interop/write-receipt-vectors.json `admittedMessageId`: a write_admitted messageId needs a letter or
// number and no Default_Ignorable code point, compared raw (never trimmed). The frame is built with this runtime's own
// encoder; the case with `wireHex` (a lone surrogate is invalid UTF-8) supplies the whole frame.
public sealed class CultNetWriteAdmittedMessageIdTests
{
private static JsonElement Vectors()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
var path = Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-receipt-vectors.json");
return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone();
}
public static IEnumerable<string> Cases() =>
Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("name").GetString()!);
[Test]
public void TheFileDeclaresHowManyCasesThisRunnerMustRun() =>
Assert.That(Cases().Count(), Is.EqualTo(Vectors().GetProperty("counts").GetProperty("admittedMessageIdCases").GetInt32()));
[TestCaseSource(nameof(Cases))]
public void AWriteAdmittedMessageIdIsRefusedOrAcceptedRaw(string name)
{
var vector = Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray()
.Single(c => c.GetProperty("name").GetString() == name);
var id = string.Concat(Enumerable.Repeat(
string.Concat(vector.GetProperty("codePoints").EnumerateArray()
.Select(cp => Convert.ToInt32(cp.GetString(), 16)).Where(point => point < 0xD800 || point > 0xDFFF).Select(char.ConvertFromUtf32)),
vector.TryGetProperty("repeat", out var repeat) ? repeat.GetInt32() : 1));
var bytes = vector.TryGetProperty("wireHex", out var wire)
? Convert.FromHexString(wire.GetString()!)
: CultNetSchemaMessageSerialization.Serialize(new CultNetWriteAdmittedMessage
{ MessageId = id, Operation = "put", SchemaId = "note.v0", RecordKey = "note:1" });
CultNetWriteAdmittedMessage? decoded = null;
try { decoded = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(bytes); }
catch (Exception) { /* a refusal */ }
if (vector.GetProperty("expect").GetString() == "refuse")
Assert.That(decoded, Is.Null, vector.GetProperty("why").GetString());
else
{
Assert.That(decoded, Is.Not.Null, vector.GetProperty("why").GetString());
Assert.That(decoded!.MessageId, Is.EqualTo(id));
}
}
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
[Test]
public void EveryRangeOfTheUnicodeTablesIsPinnedAtBothEnds()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
var ranges = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-admitted-message-id-ranges.json"))).RootElement;
var letters = ranges.GetProperty("letterOrNumber").EnumerateArray().Select(r => (r[0].GetInt32(), r[1].GetInt32())).ToArray();
var ignorable = ranges.GetProperty("defaultIgnorable").EnumerateArray().Select(r => (r[0].GetInt32(), r[1].GetInt32())).ToArray();
Assert.That(letters.Length, Is.GreaterThan(700));
Assert.That(ignorable.Length, Is.EqualTo(17));
static bool Accepted(string id)
{
try
{
CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(
new CultNetWriteAdmittedMessage { MessageId = id, Operation = "put", SchemaId = "note.v0", RecordKey = "note:1" }));
return true;
}
catch (Exception) { return false; }
}
var failures = new List<string>();
foreach (var (low, high) in letters)
foreach (var (point, want) in new[] { (low, true), (high, true), (low - 1, false), (high + 1, false) })
if (Accepted(char.ConvertFromUtf32(point)) != want) failures.Add($"U+{point:X} alone should be {(want ? "accepted" : "refused")}");
foreach (var (low, high) in ignorable)
foreach (var (point, want) in new[] { (low, false), (high, false), (low - 1, true), (high + 1, true) })
if (Accepted("a" + char.ConvertFromUtf32(point)) != want) failures.Add($"a + U+{point:X} should be {(want ? "accepted" : "refused")}");
Assert.That(failures, Is.Empty);
}
// admittedMessageIdDuplicates: C# reads the raw map, so it sees a repeated messageId key and refuses it whatever the values.
[Test]
public void ARepeatedMessageIdKeyIsRefusedWhateverTheValues()
{
var cases = Vectors().GetProperty("admittedMessageIdDuplicates").GetProperty("cases").EnumerateArray().ToArray();
Assert.That(cases.Length, Is.EqualTo(Vectors().GetProperty("counts").GetProperty("admittedMessageIdDuplicateCases").GetInt32()));
foreach (var vector in cases)
{
Assert.That(vector.GetProperty("seesDuplicate").GetString(), Is.EqualTo("refuse"));
Assert.That(() => CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString(vector.GetProperty("wireHex").GetString()!)),
Throws.Exception, vector.GetProperty("name").GetString());
}
}
}
}