Compare commits

..
74 changed files with 246 additions and 31186 deletions

View file

@ -8,12 +8,6 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
# workflow_dispatch runs are never cancelled: a tag run carries a release.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
jobs: jobs:
reactive-and-resource-lifetime: reactive-and-resource-lifetime:
name: ${{ matrix.os }} reactive semantics and leases name: ${{ matrix.os }} reactive semantics and leases
@ -22,16 +16,12 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# The forge runner (label `ubuntu-latest`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the ubuntu-latest leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner. # The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }} os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
steps: steps:
- name: Check out CultLib - name: Check out CultLib
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# The declared-dependency-range test builds the admitted releases from their tags.
fetch-depth: 0
fetch-tags: true
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@ -55,34 +45,6 @@ jobs:
- name: Install Python CultLib packages - name: Install Python CultLib packages
run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
# The forge runner images carry no cargo either (the cross-runtime store tests build the Rust interop example); GitHub's hosted images do.
- name: Install Rust toolchain
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The forge runner images carry no pwsh; GitHub's Windows and hosted images do.
- name: Install PowerShell
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
if command -v pwsh >/dev/null; then pwsh -Version; exit 0; fi
SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo"
PWSH_VERSION=7.4.6
curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-x64.tar.gz" -o "${RUNNER_TEMP:-/tmp}/powershell.tar.gz"
$SUDO mkdir -p /opt/microsoft/powershell/7
$SUDO tar -xzf "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" -C /opt/microsoft/powershell/7
$SUDO chmod +x /opt/microsoft/powershell/7/pwsh
$SUDO ln -sf /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh
echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV"
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 pwsh -Version
- name: Verify explicit reactive scheduling across runtimes - name: Verify explicit reactive scheduling across runtimes
shell: pwsh shell: pwsh
run: ./scripts/verify-reactive-document-scaling.ps1 run: ./scripts/verify-reactive-document-scaling.ps1
@ -98,8 +60,8 @@ jobs:
--logger "console;verbosity=minimal" --logger "console;verbosity=minimal"
# The TypeScript side of the CultMesh authority rules. This job is the # The TypeScript side of the CultMesh authority rules. This job is the
# only one that runs on Linux for main, codex/** and pull requests (and on # only one that runs on both Windows and Linux for main, codex/** and
# Windows too under workflow_dispatch): cultnet-interop.yml is windows-latest and dispatch-only, so # pull requests: cultnet-interop.yml is windows-latest and main-only, so
# until these steps existed the shared verifier's tests never ran on # until these steps existed the shared verifier's tests never ran on
# Linux at all, and the browser client's never ran in CI on any OS. # Linux at all, and the browser client's never ran in CI on any OS.
# packages/cultnet-ts verifies both vector files (content.test.ts judges both # packages/cultnet-ts verifies both vector files (content.test.ts judges both

View file

@ -1,7 +1,7 @@
name: CultNet cross-runtime interop name: CultNet cross-runtime interop
# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge # workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge
# runner is Ubuntu-only (no Windows runner exists; push and pull_request return in the PR that registers one), so a push or pull_request trigger would queue a job that can never run. # runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run.
# Run it from GitHub or any Windows runner. # Run it from GitHub or any Windows runner.
on: on:
workflow_dispatch: workflow_dispatch:

View file

@ -18,12 +18,6 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
# workflow_dispatch runs are never cancelled: a tag run carries a release.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
permissions: permissions:
contents: read contents: read
@ -55,12 +49,11 @@ jobs:
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds. # workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
# A dry (workflow_dispatch) run uploads artifacts and creates no release. # A dry (workflow_dispatch) run uploads artifacts and creates no release.
# No Windows runner is registered on the forge, so this job is workflow_dispatch only
# (run it on GitHub or a Windows runner). The tag trigger returns in the PR that registers one.
# Until then a tag push skips the release job, which needs this build.
build-win32-x64: build-win32-x64:
name: Build win32-x64 name: Build win32-x64
if: github.event_name == 'workflow_dispatch' if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
@ -99,8 +92,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Q6 A: this pinned Debian 13 container is the only permitted Linux # Q6 A: this pinned Debian 13 container is the only permitted Linux
# release builder. The digest is the pin; the `debian:13` tag is only a # release builder. The digest is the pin; the `debian:13` tag is only a
# label. The artifact links against this image's glibc (2.41); # label. The artifact links against this image's glibc (2.41); a bare
# a bare runner's OS image has its own glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's # ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
# OS (docs/typescript-quic-realtime-cut.md:2192-2197). # OS (docs/typescript-quic-realtime-cut.md:2192-2197).
container: container:
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,195 +0,0 @@
# CultNet fork-desk contract v0 (specification)
Status: specification, drafted for Heimdall (Bifrost receiver), Huginn (the second door) and Void (Ratatoskr desk). No runtime implements it yet.
Rule source: `/opt/commons/handoff/fork-desk-answer-binding-20261110.md` (Heimdall; sections 1.1 to 1.5, 1.3a to 1.3d), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Where this page differs from that file it says so under "Differences from the binding file".
Format source: the fork desk artifact in `/opt/commons/handoff/fork-desk-original/` (ruling `eureka-substrate:ruling:fork-desk-nibu-format`).
Files here: `fork-card.schema.json` (one schema, a `$defs` entry per document), `fork-card-vectors.json`. Conformance: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript), both generated against vectors written by `scripts/gen-governance-vectors.py`. Each suite computes every hash with its own code and replays every scenario through a reference judge written from this page.
All documents are CultNet map-mode: every key written, nil where absent, closed schemas. Field names are snake_case as in the binding file and the mind's records; `schemaVersion` keeps CultNet's spelling (open choice 1). The bytes of every document are pinned in the vectors as `messagePackHex`.
## Documents
| schema id | what | who writes it |
|---|---|---|
| `gamecult.fork.card.v0` | the fork as shown | the publisher (a Mind's card projection) |
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
| `gamecult.fork.refusal.v0` | a body refused at the door; nothing stored | Bifrost |
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
| `gamecult.fork.list.v0` | a page of entries, counts and a stamp | Bifrost, projected from Huginn |
| `gamecult.fork.read_request.v0` | read one fork (Transport) | the desk |
| `gamecult.fork.entry.v0` | one card with its record | Bifrost, projected from Huginn |
| `gamecult.fork.unavailable.v0` | the outcome is not confirmed (the second door or Huginn did not answer); retry with the same `request_id` | Bifrost |
### card
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`.
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
### Options hash (canonical bytes)
`options_hash` = lowercase hex SHA-256 of the UTF-8 bytes of the following, byte for byte:
1. Build an object with exactly the five members `title`, `context`, `options`, `recommendation`, `depends`. Each option is an object with exactly `id` and `text`.
2. **NFC-normalise every string, object keys included.** No trimming, no newline rewriting, no case folding. A string with a lone surrogate is not a card (it has no UTF-8). Normalisation stability makes NFC stable across Unicode versions for already-assigned code points; a runtime's own normaliser is trusted and the vectors include a decomposed pair and astral code points (open choice 2).
3. Serialise as RFC 8785 (JCS): no whitespace; object keys sorted by UTF-16 code unit, which for these ASCII keys is `context, depends, options, recommendation, title` and, inside an option, `id, text`; array order is as shown (option order and `depends` order are part of the hash).
4. Strings are written in double quotes; only `"` and `\` are backslash-escaped, U+0008, U+0009, U+000A, U+000C, U+000D as `\b \t \n \f \r`, every other code point below U+0020 as `\u00xx` in lowercase hex; everything else is written raw as UTF-8 (U+007F, U+2028, `<`, `&` and astral code points included).
5. **Numbers and nulls:** the member set contains none. There is no number to canonicalise (a number anywhere in the five members is a defect) and `recommendation` is never nil, so no nil is hashed.
`fork_id` is not in the hash and is bound beside it in its own field, so one hash cannot be replayed under another fork. The publisher computes the hash and stores it in the card; the client recomputes it from what it rendered and refuses to send on a mismatch; the receiver recomputes from its stored fork, never from the client.
Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed by the Python generator, by the C# suite and by the TypeScript suite independently, plus text, title, context and `depends`-order mutations, a case showing the unshown members do not move the hash, escapes and markup, and astral ids. Each suite also mutates every shown member, an option id included, and asserts the hash changes.
### answer_request
`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. **`retracted` is part of the body**: the same `request_id` with `retracted: true` and then `false` (or the reverse) and every other field equal is `request_id_conflict`, for the illegal forms as for any other (vectors `R1_retracted_true_then_false_...`, `R1_retracted_false_then_true_...`). Each illegal form's own retry, byte for byte, replays its stored `bad_retraction` (vectors `R1_a_retraction_with_an_answer_...`, `R1_a_retraction_with_no_target_...`, `R1_a_plain_answer_with_a_target_...`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse; so a target-less retraction resent with `retracted: false` (no answer, no retraction) is `body_invalid` at step 2, not a conflict. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`).
### desk_answer (the stored kind)
`{schemaVersion, answer_id, fork_id, outcome, of, retracts, code, options_hash, choice, note, answered_at, received_at, claim_in_future, answerer, authority, request_id}`, key `<campaign>:desk_answer:<question-label>.n<seq>`; `answer_id` is `n<seq>`, assigned by Huginn per question, and **an answer is named by its answer id** everywhere (`retracts`, `of`, `ruled_from`).
The stamped `authority` field of a stored answer is an **attribution**: the role the verified account holds (`Operator`, `Steward`, `Member`), copied from Bifrost's state. A stored answer confers no authority. Authority exists only where a ruling cites a counted answer ("Operator authority" below); the field keeps the binding file's spelling (open choice 8). One tagged `outcome`, each variant carrying only its own link:
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried, and so does `retracts`: a refused body that named a target (a retraction of any refusal code, or a plain answer carrying one) projects it as `n<seq>`, never nil; a refused body that named none has `retracts` nil. `of` is nil.
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
### Counting rules (in order; the first failure refuses with that code)
| step | rule | refusal | stored |
|---|---|---|---|
| 1 | verified session or bearer | `unauthenticated` (401) | no |
| 2 | closed body; no `answerer`/`authority` at any depth; type-correct; not (no `answer` and not a retraction) | `body_forbidden_field`, `body_unknown_field`, `body_invalid` (400) | no |
| 3 | `fork_id` is a stored fork | `fork_unknown` (404) | no |
| 4 | `options_hash` equals the stored fork's hash | `stale_fork` (409) | **yes**; the result carries the current card |
| 5 | the stamped `answerer` is on `deciders` | `not_a_decider` (403) | yes |
| 6 | the fork is open (`status: open`) | `fork_closed` (409) | yes |
| 7 | `choice`, if present, is an option id | `choice_invalid` (400) | yes |
| 8 | first passing answer for `(fork_id, options_hash)` is `Counted`; later passing answers are `Revision`s naming the counted one | | yes |
| 9 | a Matrix edit, redaction or reaction changes nothing | | no event is an answer |
**Retraction** (`retracted: true`, `retracts: <answer id>`, `answer: nil`, `options_hash` still carried, so step 4 applies) passes steps 1 to 6, then:
- R1 form: it carries no `answer`, it names a `Counted` or `Revision` answer of this fork under this `options_hash`; else `bad_retraction` (400). A retraction naming an answer already retracted is `already_retracted` (409). `retracts` on a body that is not a retraction is `bad_retraction`.
- R2 who: the original answerer or any other decider; step 5 already requires a decider.
- R3 is dropped and its label stays vacant, so R4 keeps its name in the binding file. A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a ruling cannot cite an answer on an open fork (that is the stale-click gap, and a card with `ruled_from` set and `status: open` is invalid), and a retraction after a ruling meets step 6: refused `fork_closed`, stored. There is no `ruled` code (vector `V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed`).
- R4 counted answer: retracting the counted answer removes it from the count and the next passing answer in admission order counts again. Retracting an answer that was not counted changes nothing. After every answer is retracted, a fresh answer is `Counted`. Records stay append-only.
**The counted answer is derived**, not stored: the first `Counted` or `Revision` answer under the card's current `options_hash` that no `Retraction` names. When the card changes, answers under the old hash stop counting and the next answer under the new hash is `Counted` (vectors `a_changed_card_...`). The `record` projection carries it as `counted_answer_id`.
### Operator authority
A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_id`) a stored `desk_answer` whose outcome is `Counted` and whose stamped attribution (the answer's `authority` field) is `Operator` (or a stamped relay of her words from a mapped door, which this contract does not model). The citation is what gives a ruling authority: an attribution on an answer is a fact about the account, and authority exists only where a ruling cites a counted answer. The cited answer id is what `ruled_from` lists on the closed card. Negative vectors (`rulingCites`): no citation, a `Revision`, a `Refused` answer, a Counted answer by a non-operator, an answer that does not exist. Other authorities need no citation.
## Transport
Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors.
**Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3).
| call | route (proposal) | request | response | token |
|---|---|---|---|---|
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` |
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list.
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals.
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`** (the admission ordinal). `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
## Old format, the mind's record, and what maps to what
The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a shared store. The mind's `question` record is the source in the new design; the card is its projection.
| old desk | mind `question` record | card |
|---|---|---|
| `qid`, `id` | key `campaign:question:label` | `fork_id` |
| `title` | `title` | `title` |
| `body` | `question` (the long text) | `context` |
| `depends`: a free string | `depends`: up to 8 prose lines (`huginn schema question`) | `depends`: the lines verbatim |
| `options[{key, label, text}]` | `options[{label, text}]` | `options[{id, text}]` |
| option `key` | option `label` (a slug, the id) | option `id` |
| option `label` (a human title) | none | **dropped**: not stored, not hashed; shown text is `text` |
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
| `order` | none (admission ordinal) | `order` |
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
| `note`: her words verbatim | | `answer.note`, verbatim |
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
| "Changing an answer is fine until it's admitted as a ruling" | | a later answer is a `Revision`; `Retraction` withdraws one; neither changes who counted first, and a ruling that answers the fork closes it, after which no answer or retraction is taken (`fork_closed`) |
Differences that need Void's choice: the old page **overwrites** one answer per question and the new store **appends**, so the booth cannot show "your answer" as one mutable field (it shows the latest answer by the person, plus its history); the old page needs no identity and the new one stamps `answerer` from a verified session; the old `label` has nowhere to live unless the card's `text` carries it.
## Transport note for Void: Matrix event versus how the mind carries questions
How a mind carries a question today: a typed `question` record in Huginn (`huginn view`, `huginn query`); the Persona relays it to the operator in chat, and the operator's reply comes back as chat text that Self reads and admits as a `ruling` in her words. Identity is whoever the chat attributes the message to; nothing binds an answer to the question text she saw.
Option A, **a Matrix event in the Mind's room with the answer as a reply.** Gains: it sits where people already are, needs no sign-in beyond Matrix, and the thread shows the exchange. Loses: the sender is an mxid, not a Bifrost account, so `answerer` and `authority` have no verified source; an edit or redaction changes the visible message but not the decision (binding rule 9), so the thread and the record can disagree; there is nowhere to carry `options_hash`, so a stale card cannot be refused; and a reply is free text, so `choice` is lost.
Option B, **Bifrost receives the answer and Huginn stores it** (the design in this page; Ratatoskr PR #24 also withdraws the Matrix carrier). Gains: a verified account, a bound hash, refusals that are stored and visible, one counted answer, retractions. Loses: the booth needs Bifrost sign-in, which does not reach chat yet (Bifrost PR #9 and #37 design it), so until then the desk is read-only; and the exchange is no longer in the room.
**Recommendation: B, with Matrix as presentation only.** The Mind may post a card as a listing and a link into the desk; no Matrix event is ever an answer (rule 9). Confidence: high on the rule (it is Heimdall's and Eureka's already), moderate on the interim, where read-only until sign-in is a real cost; if the operator needs to answer before sign-in reaches chat, the honest stopgap is the old path (chat text, Self admits a ruling in her words) rather than a Matrix reply the record cannot bind.
## Differences from the binding file
- Spelling: every field is as the binding file names it (`fork_id`, `options_hash`, `answered_at`, `retracted`, `retracts`, `answer.choice`, `answer.note`, `recommendation`, `depends`, option `id`), except that `schemaVersion` keeps CultNet's camelCase and `answer_id` replaces the earlier `receipt id` (Persona brief).
- Map-mode writes every key: `retracted` is always present; nothing is "optional".
- `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4).
- The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`.
- `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too.
- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`.
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
## Open choices
1. Case of field names: snake_case for the fork documents (as the binding file) beside camelCase for the vote documents (as Bifrost's serializer). One convention would be tidier.
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale.
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the ordinal alone decides order.
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.

View file

@ -1,986 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/fork-card.schema.json",
"title": "CultNet fork-desk contract v0",
"description": "A fork card, the answer request a decider sends, the stored desk_answer with its tagged outcome, the result and door refusals returned to the sender, the record a fork keeps, and the ruling citation. Map-mode: every key is written, nil where absent. A request never carries answerer or authority: Huginn stamps both. Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md. Spec: fork-card.md. Vectors: fork-card-vectors.json.",
"$defs": {
"hex64": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"instant": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
},
"id": {
"type": "string",
"minLength": 1
},
"answerId": {
"type": "string",
"pattern": "^n[1-9][0-9]*$"
},
"option": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"text"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"text": {
"type": "string",
"minLength": 1
}
}
},
"ref": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"type": "string",
"minLength": 1
},
"id": {
"type": "string",
"minLength": 1
}
}
},
"card": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"fork_id",
"title",
"context",
"options",
"recommendation",
"depends",
"leans",
"flag",
"raised_in",
"deciders",
"status",
"ruling",
"ruled_from",
"order",
"options_hash"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.card.v0"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"title": {
"type": "string",
"minLength": 1
},
"context": {
"type": "string"
},
"options": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/$defs/option"
}
},
"recommendation": {
"type": "string",
"minLength": 1
},
"depends": {
"type": "array",
"maxItems": 8,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 1000
}
},
"leans": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
},
"flag": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
},
"raised_in": {
"oneOf": [
{
"$ref": "#/$defs/ref"
},
{
"type": "null"
}
]
},
"deciders": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
},
"maxItems": 64
},
"status": {
"enum": [
"open",
"closed"
]
},
"ruling": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
},
"ruled_from": {
"type": "array",
"items": {
"$ref": "#/$defs/answerId"
}
},
"order": {
"type": "integer",
"minimum": 0
},
"options_hash": {
"$ref": "#/$defs/hex64"
}
}
},
"words": {
"type": "string",
"minLength": 1,
"maxLength": 4000,
"pattern": "^(?![\\t-\\r \\u0085   - 

   ]+$)[\\s\\S]+$",
"description": "At most 4000 UTF-8 bytes, which a schema cannot say: maxLength here counts code points and is only the looser bound (a code point is at least one byte). The byte limit is a rule both suites enforce (fork-card.md, answer_request)."
},
"answerObject": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"choice",
"note"
],
"properties": {
"choice": {
"$ref": "#/$defs/id"
},
"note": {
"$ref": "#/$defs/words"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"choice",
"note"
],
"properties": {
"choice": {
"$ref": "#/$defs/id"
},
"note": {
"type": "null"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"choice",
"note"
],
"properties": {
"choice": {
"type": "null"
},
"note": {
"$ref": "#/$defs/words"
}
}
}
]
},
"answerRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"fork_id",
"options_hash",
"answer",
"retracted",
"retracts",
"answered_at",
"request_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.answer_request.v0"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"answer": {
"oneOf": [
{
"$ref": "#/$defs/answerObject"
},
{
"type": "null"
}
]
},
"retracted": {
"type": "boolean"
},
"retracts": {
"oneOf": [
{
"$ref": "#/$defs/answerId"
},
{
"type": "null"
}
]
},
"answered_at": {
"$ref": "#/$defs/instant"
},
"request_id": {
"$ref": "#/$defs/requestId"
}
}
},
"deskAnswerCounted": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"answer_id",
"fork_id",
"outcome",
"of",
"retracts",
"code",
"options_hash",
"choice",
"note",
"answered_at",
"received_at",
"claim_in_future",
"answerer",
"authority",
"request_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.desk_answer.v0"
},
"answer_id": {
"$ref": "#/$defs/answerId"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"outcome": {
"const": "Counted"
},
"of": {
"type": "null"
},
"retracts": {
"type": "null"
},
"code": {
"type": "null"
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"choice": {
"oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
},
"note": {
"oneOf": [
{
"$ref": "#/$defs/words"
},
{
"type": "null"
}
]
},
"answered_at": {
"$ref": "#/$defs/instant"
},
"received_at": {
"$ref": "#/$defs/instant"
},
"claim_in_future": {
"type": "boolean"
},
"answerer": {
"$ref": "#/$defs/id"
},
"authority": {
"$ref": "#/$defs/id"
},
"request_id": {
"$ref": "#/$defs/requestId"
}
}
},
"deskAnswerRevision": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"answer_id",
"fork_id",
"outcome",
"of",
"retracts",
"code",
"options_hash",
"choice",
"note",
"answered_at",
"received_at",
"claim_in_future",
"answerer",
"authority",
"request_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.desk_answer.v0"
},
"answer_id": {
"$ref": "#/$defs/answerId"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"outcome": {
"const": "Revision"
},
"of": {
"$ref": "#/$defs/answerId"
},
"retracts": {
"type": "null"
},
"code": {
"type": "null"
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"choice": {
"oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
},
"note": {
"oneOf": [
{
"$ref": "#/$defs/words"
},
{
"type": "null"
}
]
},
"answered_at": {
"$ref": "#/$defs/instant"
},
"received_at": {
"$ref": "#/$defs/instant"
},
"claim_in_future": {
"type": "boolean"
},
"answerer": {
"$ref": "#/$defs/id"
},
"authority": {
"$ref": "#/$defs/id"
},
"request_id": {
"$ref": "#/$defs/requestId"
}
}
},
"deskAnswerRetraction": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"answer_id",
"fork_id",
"outcome",
"of",
"retracts",
"code",
"options_hash",
"choice",
"note",
"answered_at",
"received_at",
"claim_in_future",
"answerer",
"authority",
"request_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.desk_answer.v0"
},
"answer_id": {
"$ref": "#/$defs/answerId"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"outcome": {
"const": "Retraction"
},
"of": {
"type": "null"
},
"retracts": {
"$ref": "#/$defs/answerId"
},
"code": {
"type": "null"
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"choice": {
"type": "null"
},
"note": {
"type": "null"
},
"answered_at": {
"$ref": "#/$defs/instant"
},
"received_at": {
"$ref": "#/$defs/instant"
},
"claim_in_future": {
"type": "boolean"
},
"answerer": {
"$ref": "#/$defs/id"
},
"authority": {
"$ref": "#/$defs/id"
},
"request_id": {
"$ref": "#/$defs/requestId"
}
}
},
"deskAnswerRefused": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"answer_id",
"fork_id",
"outcome",
"of",
"retracts",
"code",
"options_hash",
"choice",
"note",
"answered_at",
"received_at",
"claim_in_future",
"answerer",
"authority",
"request_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.desk_answer.v0"
},
"answer_id": {
"$ref": "#/$defs/answerId"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"outcome": {
"const": "Refused"
},
"of": {
"type": "null"
},
"retracts": {
"oneOf": [
{
"$ref": "#/$defs/answerId"
},
{
"type": "null"
}
]
},
"code": {
"enum": [
"stale_fork",
"not_a_decider",
"fork_closed",
"choice_invalid",
"bad_retraction",
"already_retracted"
]
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"choice": {
"oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
},
"note": {
"oneOf": [
{
"$ref": "#/$defs/words"
},
{
"type": "null"
}
]
},
"answered_at": {
"$ref": "#/$defs/instant"
},
"received_at": {
"$ref": "#/$defs/instant"
},
"claim_in_future": {
"type": "boolean"
},
"answerer": {
"$ref": "#/$defs/id"
},
"authority": {
"$ref": "#/$defs/id"
},
"request_id": {
"$ref": "#/$defs/requestId"
}
}
},
"deskAnswer": {
"oneOf": [
{
"$ref": "#/$defs/deskAnswerCounted"
},
{
"$ref": "#/$defs/deskAnswerRevision"
},
{
"$ref": "#/$defs/deskAnswerRetraction"
},
{
"$ref": "#/$defs/deskAnswerRefused"
}
]
},
"answerResult": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"answer",
"card",
"counted_answer_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.answer_result.v0"
},
"answer": {
"$ref": "#/$defs/deskAnswer"
},
"card": {
"oneOf": [
{
"$ref": "#/$defs/card"
},
{
"type": "null"
}
]
},
"counted_answer_id": {
"oneOf": [
{
"$ref": "#/$defs/answerId"
},
{
"type": "null"
}
]
}
}
},
"refusal": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"code",
"error"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.refusal.v0"
},
"code": {
"enum": [
"unauthenticated",
"body_forbidden_field",
"body_unknown_field",
"body_invalid",
"fork_unknown",
"insufficient_scope",
"forbidden",
"request_invalid",
"request_id_conflict"
]
},
"error": {
"type": "string",
"minLength": 1
}
}
},
"record": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"fork_id",
"options_hash",
"counted_answer_id",
"answers"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.record.v0"
},
"fork_id": {
"$ref": "#/$defs/id"
},
"options_hash": {
"$ref": "#/$defs/hex64"
},
"counted_answer_id": {
"oneOf": [
{
"$ref": "#/$defs/answerId"
},
{
"type": "null"
}
]
},
"answers": {
"type": "array",
"items": {
"$ref": "#/$defs/deskAnswer"
}
}
}
},
"rulingCite": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"ruling_id",
"authority",
"answer_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.ruling_cite.v0"
},
"ruling_id": {
"type": "string",
"minLength": 1
},
"authority": {
"type": "string",
"minLength": 1
},
"answer_id": {
"oneOf": [
{
"$ref": "#/$defs/answerId"
},
{
"type": "null"
}
]
}
}
},
"cursor": {
"type": "string",
"pattern": "^c[1-9][0-9]*$",
"description": "Opaque to a client. c<N> means: the cards whose order is below N."
},
"stamp": {
"type": "string",
"pattern": "^s[0-9]+$",
"description": "s<N>, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents. Two lists with the same stamp are the same state."
},
"listRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"mind",
"status",
"cursor",
"limit"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.list_request.v0"
},
"mind": {
"$ref": "#/$defs/id"
},
"status": {
"oneOf": [
{
"enum": [
"open",
"recorded"
]
},
{
"type": "null"
}
]
},
"cursor": {
"oneOf": [
{
"$ref": "#/$defs/cursor"
},
{
"type": "null"
}
]
},
"limit": {
"type": "integer",
"minimum": 0,
"maximum": 50
}
}
},
"readRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"mind",
"fork_id"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.read_request.v0"
},
"mind": {
"$ref": "#/$defs/id"
},
"fork_id": {
"$ref": "#/$defs/id"
}
}
},
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"card",
"record"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.entry.v0"
},
"card": {
"$ref": "#/$defs/card"
},
"record": {
"$ref": "#/$defs/record"
}
}
},
"list": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"mind",
"stamp",
"open_count",
"recorded_count",
"forks",
"next_cursor"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.list.v0"
},
"mind": {
"$ref": "#/$defs/id"
},
"stamp": {
"$ref": "#/$defs/stamp"
},
"open_count": {
"type": "integer",
"minimum": 0
},
"recorded_count": {
"type": "integer",
"minimum": 0
},
"forks": {
"type": "array",
"maxItems": 50,
"items": {
"$ref": "#/$defs/entry"
}
},
"next_cursor": {
"oneOf": [
{
"$ref": "#/$defs/cursor"
},
{
"type": "null"
}
]
}
}
},
"unavailable": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"code",
"error",
"retryAfterSeconds"
],
"properties": {
"schemaVersion": {
"const": "gamecult.fork.unavailable.v0"
},
"code": {
"const": "unavailable"
},
"error": {
"type": "string",
"minLength": 1
},
"retryAfterSeconds": {
"oneOf": [
{
"type": "integer",
"minimum": 1
},
{
"type": "null"
}
]
}
},
"description": "The outcome is NOT CONFIRMED: the request may have reached Huginn and been stored before the reply was lost. Retry with the same request_id to learn the outcome."
},
"requestId": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
"description": "Set by the client, a lowercase D-format UUID. Bound to the stamped answerer by the first stored answer that carries it."
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
# CultNet vote API v0 (specification)
Status: specification, drafted for Heimdall (Bifrost) and a Ratatoskr voting module. No server implements it yet.
Files here: `vote-api.schema.json` (one schema, a `$defs` entry per document), `vote-api-vectors.json`. Conformance suites: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript). The vectors are written by `scripts/gen-governance-vectors.py`.
Facts it rests on: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Bifrost `416f11a`; `416f11a..276f611` changes four files: a Standard-close concurrency fix in `MotionGovernanceService.cs`, two test files and `docs/deploy-main-to-live.md`; none touches the sealed API, so the facts hold at `276f611`) and Bifrost PR #37 (`docs/ratatoskr-voting-auth.md`, see "PR #37" below).
## Scope
**Which API covers which.** This contract covers **sealed-ballot motions only** (`gamecult.sealed_ballot.v0`). **Standard motions are not covered here.** Bifrost PR #37 also serves them (`PUT /api/votes/motions/{id}/ballot`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`), and that API is #37's own: this page defines no document for it, and a Standard motion id sent to any exchange below is answered `not_found`. Today Standard motions have no JSON read or cast API at all, no Mind path, and no receipt (open choice 1). The contract names four exchanges and one refusal document:
| exchange | request | response |
|---|---|---|
| motion list | `gamecult.vote.motion_list_request.v0` | `gamecult.vote.motion_list.v0` |
| motion read | `gamecult.vote.motion_read_request.v0` | `gamecult.vote.motion.v0` |
| cast (commit or reveal) | `gamecult.vote.cast.v0` | `gamecult.vote.receipt.v0` |
| any refusal | | `gamecult.vote.refusal.v0` |
Every document is CultNet map-mode: all keys are written, nil where absent, and the schema is closed (`additionalProperties: false`). Instants are RFC 3339 UTC with a `Z` and up to seven fractional digits. Ids are lowercase `D`-format UUIDs. A motion states `highStakes`, a boolean fixed when the motion opens and never changed after (Bifrost PR #37, D2): it is the property that makes a commit demand a fresh sign-in (`reauthentication_required`). The bytes of every document are pinned by `messagePackHex` in the vectors (shortest integers, shortest strings, keys in the order the schema lists them); JSON with the same keys is the xenos-boundary form of the same documents (open choice 4: the wire format).
## Documents
- **motion_list_request**: `{schemaVersion, phase}`; `phase` is `Commit`, `Reveal`, `Closed` or nil for all.
- **motion_list**: `{schemaVersion, motions: [{id, title, round, phase, commitClosesAtUtc, closesAtUtc, highStakes}]}`. **Heimdall must add this endpoint**: Bifrost has no list route for sealed motions today, only fetch-by-id. No pagination in v0 (open choice 2).
- **motion_read_request**: `{schemaVersion, motionId}`.
- **motion**: Bifrost's `SealedMotionState` with its names: `{schemaVersion, id, title, summary, round, priorMotionId, ballotSchema, phase, candidates (two or more), commitClosesAtUtc, closesAtUtc, highStakes, yourVoterId, yourCommitment (a receipt or nil), announcement}`. It never carries a tally, a turnout, or another voter's commitment or ballot (a `tally` key is a negative vector). `yourVoterId` is the voter's `UserAccount.Id`, the `voterUserAccountId` of the commitment.
- **cast**: `{schemaVersion, motionId, verb, commitment, ballot}` with exactly one of two shapes: `verb: "commit"` with a 64-hex `commitment` and `ballot: nil`, or `verb: "reveal"` with `commitment: nil` and a `ballot`. One document for both verbs; today's two routes (`/commit`, `/reveal`) may stay, taking this document as the body, with a `verb` that must match the route (open choice 3).
- **ballot** (inside a reveal): `{schema: "gamecult.sealed_ballot.v0", motionId, round, candidate, reason, salt, persona}`. `candidate` and `reason` are single-line and **already trimmed** (no Unicode White_Space at either end, using the White_Space list of `write-receipt.md`); the contract refuses an untrimmed value rather than trimming it, so the bytes hashed are the bytes sent. **This is a behaviour change Bifrost must make:** it trims `candidate` and `reason` silently on reveal today (facts file section 3c, `SealedBallotService.cs:154, 385-387`), so an untrimmed reveal is accepted and hashed trimmed; under this contract it is `invalid` (open choice 5). `reason` is 1 to 280 characters counted in UTF-16 code units, as Bifrost counts. A JSON Schema `maxLength` counts code points, so the schema alone accepts up to 280 astral characters; both suites add the rule (vectors `cast_reveal_reason_140_astral_280_units` valid, `ballot_reason_141_astral_282_units` refused). `salt` is 32 to 128 lowercase hex. `persona` is `""` for a member and a printable-ASCII, space-free id of at most 64 characters for a Mind.
- **receipt**: Bifrost's `SealedBallotReceipt`: `{schemaVersion, motionId, voterUserAccountId, commitment, revision (>= 1), committedAtUtc, revealed}`. The receipt for a reveal has `revealed: true` and **echoes nothing of the ballot** (no candidate, reason or salt; negative vectors for each).
- **refusal**: `{schemaVersion, code, error, retryAfterSeconds}`; the `code` set is closed:
| code | HTTP | trigger |
|---|---|---|
| `unauthenticated` | 401 | no bearer, or one Heimdall does not know (the API must answer this, not redirect to a sign-in page) |
| `reauthentication_required` | 401 | a commit on a `highStakes` motion with no sign-in fresh enough (#37 D2: `prompt=login`, `max_age`); the voter signs in again at the top level and retries |
| `forbidden` | 403 | authenticated but not an Active member, or a Mind key used on a route it may not call |
| `not_found` | 404 | unknown motion id, a Standard motion id, or a reveal with no prior commit |
| `wrong_phase` | 409 | commit outside Commit, reveal outside Reveal |
| `contended` | 409 | a reveal raced the close fence: send it again |
| `invalid` | 400 | the body fails the schema or an in-document rule (untrimmed text, candidate not in the motion, `round` or `motionId` mismatch, `persona` wrong for the credential) |
| `commitment_mismatch` | 400 | a reveal whose canonical bytes do not hash to the stored commitment |
| `payload_too_large` | 413 | body over 4096 bytes |
| `rate_limited` | 429 | cast rate exceeded, per account; `retryAfterSeconds >= 1`, mirrored in `Retry-After` |
| `held` | 503 | the Ratatoskr voting brake is set (#37 D5): nothing is read or stored while it is |
Each code has a vector (`refusals`), and every failure of the schema has a negative vector (`invalid`).
**Not on Bifrost's member API yet.** The **413 body cap of 4096 bytes** and the **429 per-account cast limit** are not on Bifrost's member sealed API today (facts file sections 3c and 5): both exist only on the Mind intake route (4096 bytes; 30 requests a minute per source address). #37 proposes 30 casts per 10 minutes per account for the member routes (its D6). `reauthentication_required` and `held` are #37 proposals (D2, D5) and exist nowhere in code. All four are spec text until Heimdall builds them.
**Two refusals Bifrost must split first.** Bifrost returns a reveal whose bytes do not hash to the stored commitment as a plain `400` `Invalid` ("The ballot does not match the stored commitment."), the same status and error type as every other bad body, so `commitment_mismatch` and `invalid` are one today. It returns a contended reveal (a race with the close fence, `SealedBallotService.cs:186-189`) as `WrongPhase` `409`, the same type as a reveal outside the Reveal phase, so `contended` and `wrong_phase` are one today. A client cannot tell "send it again" from "too late", or "wrong ballot" from "malformed body", until Bifrost splits each pair.
## Commitment
`commitment = lowercase hex SHA-256 of the UTF-8 bytes of`
gamecult.sealed_ballot.v0 \n {motionId} \n {voterUserAccountId} \n {persona} \n {round} \n {candidate} \n {reason} \n {salt}
with `\n` U+000A, ids in `D` format, `round` in decimal, `persona` empty for a member. This is Bifrost's formula as the code has it (`SealedBallotService.cs:154, 385-387`); `docs/sealed-ballot-motion.md:22-23` omits `{persona}` and is stale. The vectors give the canonical bytes (`canonicalHex`) and the digest for a member, a Mind, non-ASCII text, and a decomposed-versus-precomposed pair (no normalisation: they are different ballots). `pairs` pins what a reveal must match: the right ballot matches; a changed reason, another voter id, or a dropped persona does not, and the answer is `commitment_mismatch`.
## Receipts the voter can check later
A cast returns the receipt. The voter checks it later in two places that must agree byte for byte with it: `motion.yourCommitment` on a motion read (a commit receipt is present from the commit; the revision counts re-commits), and, after close, the `commitment` the result lists for them. The contract adds no receipt id or signature: the receipt is the commitment, its revision and the time (open choice 6).
## Personas
A Persona votes only on sealed motions, through a Mind key (Bifrost binds an Ed25519 public key 1:1 to an Active member account). The ballot's `persona` is the bound persona id and the commitment covers it, so a Mind's ballot cannot be replayed as a member's (`pairs`: dropping the persona does not match). The Mind-intake route today takes a signed `MindEnvelope` anonymously; this contract does not model that envelope, nonce or signature (open choice 7). Standard motions have no Mind path.
## Authentication
On the wire the credential is an opaque `Authorization: Bearer` header. The token route, claims, scopes and audience belong to Heimdall and Bifrost and are not designed here. A request without a usable bearer is answered `unauthenticated` as a typed refusal.
**Auth per route, now and after #37.** Routes are Bifrost's; this contract fixes the documents, not the paths (open choice 3).
| exchange | today (`/api/sealed-motions`, facts file 3c) | after #37 (`/api/votes`) |
|---|---|---|
| motion list | no route | bearer, scope `votes.read` |
| motion read | session cookie + Active member | bearer, scope `votes.read` |
| commit, reveal | session cookie + Active member | bearer, scope `votes.cast`, audience `bifrost-votes`; a high-stakes commit also needs a fresh sign-in |
| Mind cast | anonymous; the Ed25519 signature on a `MindEnvelope` is the credential | unchanged: Minds never use `/api/votes` (#37) |
| open a motion, Mind-key registry | session cookie + moderator | unchanged, cookie only |
Today a member route has **no bearer scheme**: it accepts the host-only `__Host-bifrost_session` cookie and answers an unauthenticated call with a redirect to the sign-in page, not a 401. After #37 `/api/votes` is bearer-only and answers a typed 401. The wire header stays an opaque bearer either way; only Bifrost knows which of the two it is checking.
## PR #37 (Bifrost `docs/ratatoskr-voting-auth.md`, head `447a7cb`)
Checked 2026-11-10 by an Eyes pass over the PR and the schema; conflicts for Heimdall:
1. **Standard motions.** The PR serves them (`PUT /api/votes/motions/{id}/ballot {choice, comment?}`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`); the contract is sealed-only and answers `not_found` for a Standard id.
2. **Verb model.** The PR keeps `POST .../commit` and `.../reveal` and adds `GET .../result`; the contract has one `cast` document and no result document.
3. **List and read as GET.** The PR names GET routes; the contract's request documents carry a `phase` filter and a `motionId`. The mapping (query string and path) is undefined until Heimdall fixes the routes.
4. **Refusals.** The PR lists 401/403/404/409/429 with `WWW-Authenticate`; the contract adds 400 (`invalid`, `commitment_mismatch`) and 413, and a typed body.
5. **Minds.** The PR says Minds never use `/api/votes` and keeps the anonymous signed `/mind` route; the contract carries `persona` in the ballot and a `forbidden` case for a Mind key on a route it may not call. The PR's token is never a Persona's.
6. **Authority.** The contract's `unauthenticated` says a bearer "Heimdall does not know"; the PR makes Bifrost the OIDC issuer (a second client, scopes `votes.read` and `votes.cast`, audience `bifrost-votes`) and notes that Bifrost OIDC is not live (its D7).
7. **Rate limits.** The PR keys per account (30 casts per 10 minutes); the contract now says per account too, and neither exists on the member API yet (above).
8. **Wire format.** The PR's errors are JSON; the contract's vectors are MessagePack. Nothing settles the media type.
9. **The PR's seven open decisions** (consent for `votes.cast`, re-authentication, no refresh token, grant revocation, a Ratatoskr brake, rate numbers, MAS rollout order) assume nothing the contract contradicts. D2 (re-authentication at cast) is answered yes for sealed commit in this contract: `reauthentication_required`, with `highStakes` fixed at open. D5 (a brake) is `held`.
## Open choices for Heimdall
1. Standard motions in v0 or a v1 (the PR already serves them).
2. Pagination and filters on the list.
3. One `cast` route or the two existing ones; `verb` redundancy.
4. Wire format: MessagePack documents with a JSON export, or JSON only (the PR is JSON).
5. Refuse untrimmed `candidate` and `reason` (this contract) or keep trimming server-side.
6. Whether a receipt should carry an id or a server signature so a voter can prove it to a third party.
7. The Mind path: keep the signed envelope on its own route, or take Mind keys as a bearer credential.
8. `reason` length in UTF-16 code units (as Bifrost) or code points.
9. Whether the result document (`GET .../result`) joins this contract.

View file

@ -1,420 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/vote-api.schema.json",
"title": "CultNet vote API v0 (sealed-ballot motions)",
"description": "Typed documents for listing, reading and casting on sealed-ballot motions, and the receipt a cast returns. Map-mode: every key is written, nil where absent. Spec: vote-api.md. Vectors: vote-api-vectors.json.",
"$defs": {
"uuid": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"instant": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
},
"hex64": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"trimmedLine": {
"type": "string",
"minLength": 1,
"pattern": "^(?![\\t-\\r \\u0085   - 

   ])[^\\n\\r\\u0085

]+(?<![\\t-\\r \\u0085   - 

   ])$"
},
"ballot": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"motionId",
"round",
"candidate",
"reason",
"salt",
"persona"
],
"properties": {
"schema": {
"const": "gamecult.sealed_ballot.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"round": {
"type": "integer",
"minimum": 1
},
"candidate": {
"$ref": "#/$defs/trimmedLine"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 280,
"pattern": "^(?![\\t-\\r \\u0085   - 

   ])[^\\n\\r\\u0085

]+(?<![\\t-\\r \\u0085   - 

   ])$"
},
"salt": {
"type": "string",
"pattern": "^[0-9a-f]{32,128}$"
},
"persona": {
"type": "string",
"pattern": "^[!-~]{0,64}$"
}
}
},
"motionListRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"phase"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_list_request.v0"
},
"phase": {
"oneOf": [
{
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
{
"type": "null"
}
]
}
}
},
"motionListItem": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"title",
"round",
"phase",
"commitClosesAtUtc",
"closesAtUtc",
"highStakes"
],
"properties": {
"id": {
"$ref": "#/$defs/uuid"
},
"title": {
"type": "string",
"minLength": 1
},
"round": {
"type": "integer",
"minimum": 1
},
"phase": {
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
"commitClosesAtUtc": {
"$ref": "#/$defs/instant"
},
"closesAtUtc": {
"$ref": "#/$defs/instant"
},
"highStakes": {
"type": "boolean",
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
}
}
},
"motionList": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motions"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_list.v0"
},
"motions": {
"type": "array",
"items": {
"$ref": "#/$defs/motionListItem"
}
}
}
},
"motionReadRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_read_request.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
}
}
},
"receipt": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"voterUserAccountId",
"commitment",
"revision",
"committedAtUtc",
"revealed"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.receipt.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"voterUserAccountId": {
"$ref": "#/$defs/uuid"
},
"commitment": {
"$ref": "#/$defs/hex64"
},
"revision": {
"type": "integer",
"minimum": 1
},
"committedAtUtc": {
"$ref": "#/$defs/instant"
},
"revealed": {
"type": "boolean"
}
}
},
"motion": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"id",
"title",
"summary",
"round",
"priorMotionId",
"ballotSchema",
"phase",
"candidates",
"commitClosesAtUtc",
"closesAtUtc",
"highStakes",
"yourVoterId",
"yourCommitment",
"announcement"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion.v0"
},
"id": {
"$ref": "#/$defs/uuid"
},
"title": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string"
},
"round": {
"type": "integer",
"minimum": 1
},
"priorMotionId": {
"oneOf": [
{
"$ref": "#/$defs/uuid"
},
{
"type": "null"
}
]
},
"ballotSchema": {
"const": "gamecult.sealed_ballot.v0"
},
"phase": {
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
"candidates": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/$defs/trimmedLine"
}
},
"commitClosesAtUtc": {
"$ref": "#/$defs/instant"
},
"closesAtUtc": {
"$ref": "#/$defs/instant"
},
"highStakes": {
"type": "boolean",
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
},
"yourVoterId": {
"$ref": "#/$defs/uuid"
},
"yourCommitment": {
"oneOf": [
{
"$ref": "#/$defs/receipt"
},
{
"type": "null"
}
]
},
"announcement": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
}
},
"commitCast": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"verb",
"commitment",
"ballot"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.cast.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"verb": {
"const": "commit"
},
"commitment": {
"$ref": "#/$defs/hex64"
},
"ballot": {
"type": "null"
}
}
},
"revealCast": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"verb",
"commitment",
"ballot"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.cast.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"verb": {
"const": "reveal"
},
"commitment": {
"type": "null"
},
"ballot": {
"$ref": "#/$defs/ballot"
}
}
},
"cast": {
"oneOf": [
{
"$ref": "#/$defs/commitCast"
},
{
"$ref": "#/$defs/revealCast"
}
]
},
"refusal": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"code",
"error",
"retryAfterSeconds"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.refusal.v0"
},
"code": {
"enum": [
"unauthenticated",
"forbidden",
"reauthentication_required",
"not_found",
"wrong_phase",
"contended",
"invalid",
"commitment_mismatch",
"payload_too_large",
"rate_limited",
"held"
]
},
"error": {
"type": "string",
"minLength": 1
},
"retryAfterSeconds": {
"oneOf": [
{
"type": "integer",
"minimum": 1
},
{
"type": "null"
}
]
}
}
}
}
}

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,89 +0,0 @@
# CultNet write receipt (specification)
Status: specification. No runtime implements it yet; the conformance follow-ups below do.
Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission).
Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them.
## The problem
A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer).
## Frames
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
**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.
## Capability: opting in
A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end.
**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in.
## Server rules
1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers.
2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission.
3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read.
4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer.
5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.**
- Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice.
- Same key, same content hash, answer was a refusal: the same refusal again.
- **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged.
- **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically.
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A 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. 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.
## Client rules: three outcomes
A write settles as exactly one of:
- **admitted**: a `write_admitted` whose `messageId` is the write's.
- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error.
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered; a write registered after the session ended settles this way when it is sent) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above.
**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing.
**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids.
## Changes to what #55 to #58 shipped
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
## Vectors
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `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
| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) |
|---|---|---|
| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies |
| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies |
| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) |
| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a |
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
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

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

View file

@ -474,14 +474,3 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`. The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
It also runs the built-in RUDP packet fixture and localhost UDP socket It also runs the built-in RUDP packet fixture and localhost UDP socket
self-test. 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,7 +26,4 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt & $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" } 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" Write-Host "Built $jarPath"

View file

@ -19,7 +19,6 @@ import java.net.Socket
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.URI import java.net.URI
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.SecureRandom import java.security.SecureRandom
import java.security.MessageDigest import java.security.MessageDigest
@ -1101,9 +1100,9 @@ data class CultNetRawDocumentRecord(
"payloadEncoding" to payloadEncoding, "payloadEncoding" to payloadEncoding,
"payload" to payload.copyOf(), "payload" to payload.copyOf(),
) )
if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId
if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId
if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole
if (tags.isNotEmpty()) wire["tags"] = tags if (tags.isNotEmpty()) wire["tags"] = tags
return wire return wire
} }
@ -2365,9 +2364,6 @@ fun parseCultNetMessage(payload: ByteArray): CultNetMessage {
if (schemaVersion !is String || schemaVersion.isBlank()) { if (schemaVersion !is String || schemaVersion.isBlank()) {
throw IOException("CultNet schema-v0 messages must declare schemaVersion") 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?>() val body = linkedMapOf<String, Any?>()
for ((key, value) in decoded) { for ((key, value) in decoded) {
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings") if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
@ -3417,7 +3413,6 @@ fun main(args: Array<String>) {
rudpClientBindsLoopbackOnlyForALoopbackEndpoint() rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames() rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames() rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
return return
} }
@ -3429,7 +3424,6 @@ fun main(args: Array<String>) {
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options) "rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
"serve" -> interopServe(options) "serve" -> interopServe(options)
"dial" -> interopDial(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]}") else -> error("Unknown mode ${args[0]}")
} }
} }
@ -6155,8 +6149,7 @@ private fun requireSchemaResponse(message: CultNetMessage?, expectedSchemaVersio
return response return response
} }
/** [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) {
class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = false) {
private val input = DataInputStream(ByteArrayInputStream(payload)) private val input = DataInputStream(ByteArrayInputStream(payload))
private var pushed = -1 private var pushed = -1
@ -6311,10 +6304,7 @@ class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = fa
code == 0xdb -> input.readInt() code == 0xdb -> input.readInt()
else -> throw IOException("expected string") else -> throw IOException("expected string")
} }
val bytes = readPayload(length) return String(readPayload(length), StandardCharsets.UTF_8)
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 { private fun readBinaryAfterCode(code: Int): ByteArray {

View file

@ -1,129 +0,0 @@
// 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

@ -1,286 +0,0 @@
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

@ -1,310 +0,0 @@
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

@ -1,77 +0,0 @@
//! 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(())
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -24,8 +24,7 @@
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "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", "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": [ "keywords": [
"cultmesh", "cultmesh",

View file

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

View file

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

View file

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

View file

@ -1,434 +0,0 @@
// 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,21 +287,18 @@ test("a replaced RUDP generation drops operations queued behind an active handle
runtimeId: identity.serviceInstanceId, runtimeId: identity.serviceInstanceId,
connectionId, connectionId,
sessionToken: "signed-provider-token", sessionToken: "signed-provider-token",
operationTimeoutMs: 1, // expires long before the test ends: the settle-by-timeout path is always exercised operationTimeoutMs: 100,
socketFactory: () => socket, socketFactory: () => socket,
}).connect(identity, new AbortController().signal); }).connect(identity, new AbortController().signal);
try { 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( const first = connection.register(
{ identity, requestedLeaseDurationMs: 10_000 }, { identity, requestedLeaseDurationMs: 10_000 },
new AbortController().signal, new AbortController().signal,
).then(() => undefined, () => undefined); );
const second = connection.register( const second = connection.register(
{ identity, requestedLeaseDurationMs: 10_000 }, { identity, requestedLeaseDurationMs: 10_000 },
new AbortController().signal, new AbortController().signal,
).then(() => undefined, () => undefined); );
await waitFor(() => handlerCalls === 1, "first queued provider operation"); await waitFor(() => handlerCalls === 1, "first queued provider operation");
const replacement = new CultNetRudpSession({ connectionId }); const replacement = new CultNetRudpSession({ connectionId });
@ -321,7 +318,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"); assert.equal(handlerCalls, 1, "transport-queued work from the closed generation must be dropped");
connection.close(); connection.close();
await Promise.all([first, second]); await Promise.allSettled([first, second]);
} finally { } finally {
connection.close(); connection.close();
server.close(); server.close();

View file

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

View file

@ -2,7 +2,6 @@
## Unreleased (breaking; version 0.x, so the next minor) ## 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 `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per - Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by

View file

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

View file

@ -6,8 +6,6 @@ from typing import Any
import msgpack import msgpack
from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER
@dataclass(frozen=True) @dataclass(frozen=True)
class CultNetMessage: class CultNetMessage:
@ -31,11 +29,7 @@ def parse_message(payload: bytes) -> CultNetMessage:
body = dict(decoded) body = dict(decoded)
del body["schemaVersion"] del body["schemaVersion"]
if schema_version == "cultnet.error.v0": if schema_version == "cultnet.error.v0":
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES) _validate_error_body(body)
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) return CultNetMessage(schema_version=schema_version, body=body)
@ -53,13 +47,6 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
"message_invalid", "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") _ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
@ -67,80 +54,16 @@ def _is_uint(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0 return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _is_name(value: Any) -> bool: def _validate_error_body(body: dict[str, Any]) -> None:
return isinstance(value, str) and value != "" """Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
expected = ("error", "routingHint", "code", "details")
# 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): if set(body) != set(expected):
raise ValueError(f"cultnet.write_admitted.v0 must carry exactly {list(expected)}, got {sorted(body)}") raise ValueError(f"cultnet.error.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"]: if not isinstance(body["error"], str) or not body["error"]:
raise ValueError(f"{version} error must be a non-empty string") raise ValueError("cultnet.error.v0 error must be a non-empty string")
code = body["code"] code = body["code"]
if code is not None and (not isinstance(code, str) or code not in codes): if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
raise ValueError(f"{version} code {code!r} is not a recognized refusal code") raise ValueError(f"cultnet.error.v0 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"] hint = body["routingHint"]
if hint is not None: if hint is not None:
if ( if (
@ -149,17 +72,17 @@ def _validate_error_body(body: dict[str, Any], version: str, codes: tuple[str, .
or not isinstance(hint["reason"], str) or not isinstance(hint["reason"], str)
or not (hint["shard"] is None or isinstance(hint["shard"], dict)) or not (hint["shard"] is None or isinstance(hint["shard"], dict))
): ):
raise ValueError(f"{version} routingHint must be null or {{shard, reason}}") raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
details = body["details"] details = body["details"]
if details is not None: if details is not None:
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS): if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
raise ValueError(f"{version} details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}") raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
for key in ("field", "value"): for key in ("field", "value"):
if details[key] is not None and not isinstance(details[key], str): if details[key] is not None and not isinstance(details[key], str):
raise ValueError(f"{version} details.{key} must be a string or null") raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
for key in ("asOf", "current"): for key in ("asOf", "current"):
if details[key] is not None and not _is_uint(details[key]): if details[key] is not None and not _is_uint(details[key]):
raise ValueError(f"{version} details.{key} must be a non-negative integer or null") raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
def hello( def hello(
@ -169,18 +92,17 @@ def hello(
supported_schema_versions: list[str] | None = None, supported_schema_versions: list[str] | None = None,
supported_mutation_contracts: list[dict[str, Any]] | None = None, supported_mutation_contracts: list[dict[str, Any]] | None = None,
transport_profiles: list[dict[str, Any]] | None = None, transport_profiles: list[dict[str, Any]] | None = None,
supported_message_versions: list[str] | None = None,
) -> CultNetMessage: ) -> CultNetMessage:
body: dict[str, Any] = { return CultNetMessage(
"runtimeId": runtime_id, "cultnet.hello.v0",
"supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"], {
"supportedSchemaVersions": supported_schema_versions or [], "runtimeId": runtime_id,
"supportedMutationContracts": supported_mutation_contracts or [], "supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"],
"transportProfiles": transport_profiles or [], "supportedSchemaVersions": supported_schema_versions or [],
} "supportedMutationContracts": supported_mutation_contracts or [],
if supported_message_versions is not None: "transportProfiles": transport_profiles or [],
body["supportedMessageVersions"] = supported_message_versions },
return CultNetMessage("cultnet.hello.v0", body) )
def login( def login(
@ -250,7 +172,7 @@ def error_message(
} }
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS): 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))}") raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES) _validate_error_body(body)
return CultNetMessage("cultnet.error.v0", body) return CultNetMessage("cultnet.error.v0", body)

View file

@ -1,127 +0,0 @@
# 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

@ -1,310 +0,0 @@
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

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

View file

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

View file

@ -468,14 +468,6 @@ impl CultNetRudpSession {
self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec()) self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec())
} }
/// Ends a session because the server itself could not answer a write it owes an answer to (its answer
/// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the
/// "session refused a packet" of a protocol refusal.
pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket {
self.reset_peer_state();
self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec())
}
/// Ends a session that owes its peer a packet that can never be sent as /// Ends a session that owes its peer a packet that can never be sent as
/// built (see `is_permanent_send_error`). Resending it would fail forever, /// built (see `is_permanent_send_error`). Resending it would fail forever,
/// and dropping it would leave a reliable sequence the peer waits on for /// and dropping it would leave a reliable sequence the peer waits on for
@ -1277,12 +1269,6 @@ 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 /// 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 /// 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 /// watermark. The watermark starts at the handshake's seed, so a frame the
@ -2623,8 +2609,6 @@ where
/// Sent to a peer whose session refused one of its packets, and reported to /// Sent to a peer whose session refused one of its packets, and reported to
/// the local caller as the disconnect reason. /// the local caller as the disconnect reason.
const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet";
/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`).
const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write";
const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out";
/// Prefix of the goodbye reason for a session that owed a packet which can /// Prefix of the goodbye reason for a session that owed a packet which can
/// never be sent; the failure's fixed name (`send_error_code`) follows it. /// never be sent; the failure's fixed name (`send_error_code`) follows it.

View file

@ -1,147 +0,0 @@
// 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

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

@ -7,8 +7,6 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Added ### Added
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`. - `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority. - `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members. - `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
@ -18,7 +16,6 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Breaking ### Breaking
- The Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success.
- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged. - The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged.
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types. - `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.

View file

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

View file

@ -9,9 +9,6 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json";
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json"; import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json"; import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json"; import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
import { 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 sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json"; import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json"; import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
@ -65,8 +62,6 @@ export type CultNetSchemaVersion =
| "cultnet.verify.v0" | "cultnet.verify.v0"
| "cultnet.login_success.v0" | "cultnet.login_success.v0"
| "cultnet.error.v0" | "cultnet.error.v0"
| "cultnet.error.v1"
| "cultnet.write_admitted.v0"
| "cultnet.sample.change_name.v0" | "cultnet.sample.change_name.v0"
| "cultnet.sample.chat.v0" | "cultnet.sample.chat.v0"
| "cultnet.document_put.v0" | "cultnet.document_put.v0"
@ -206,11 +201,6 @@ export const CULTNET_ERROR_CODES = [
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */
export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const;
export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_CODES)[number];
/** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */ /** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */
export function cultNetTextError(error: string): CultNetErrorMessage { export function cultNetTextError(error: string): CultNetErrorMessage {
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null }; return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
@ -265,34 +255,6 @@ export interface CultNetErrorMessage {
details: CultNetErrorDetails | null; details: CultNetErrorDetails | null;
} }
/**
* cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a
* connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md).
*/
export interface CultNetErrorV1Message extends Omit<CultNetErrorMessage, "schemaVersion" | "code"> {
schemaVersion: "cultnet.error.v1";
code: CultNetErrorV1Code | null;
messageId: string | null;
}
/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */
export interface CultNetWriteAdmittedMessage {
schemaVersion: "cultnet.write_admitted.v0";
messageId: string;
operation: "put" | "delete";
schemaId: string;
recordKey: string;
}
/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */
export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const;
/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */
export function helloAdvertisesWriteReceipts(hello: Pick<CultNetHelloMessage, "supportedMessageVersions">): boolean {
const listed = hello.supportedMessageVersions ?? [];
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version));
}
export interface CultNetSampleChangeNameMessage { export interface CultNetSampleChangeNameMessage {
schemaVersion: "cultnet.sample.change_name.v0"; schemaVersion: "cultnet.sample.change_name.v0";
name: string; name: string;
@ -808,8 +770,6 @@ export interface GhostlightAgentStateDocument {
export type CultNetMessage = export type CultNetMessage =
| CultNetHelloMessage | CultNetHelloMessage
| CultNetErrorV1Message
| CultNetWriteAdmittedMessage
| CultNetLoginMessage | CultNetLoginMessage
| CultNetRegisterMessage | CultNetRegisterMessage
| CultNetVerifyMessage | CultNetVerifyMessage
@ -852,8 +812,6 @@ const CULTNET_MESSAGE_SCHEMAS = [
verifySchema, verifySchema,
loginSuccessSchema, loginSuccessSchema,
errorSchema, errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema, sampleChangeNameSchema,
sampleChatSchema, sampleChatSchema,
documentPutSchema, documentPutSchema,
@ -951,10 +909,6 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
return input as CultNetDatabaseChangeRawMessage; 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)) { if (!validator(input)) {
const keys = Object.keys(input as Record<string, unknown>).join(","); const keys = Object.keys(input as Record<string, unknown>).join(",");
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`); throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
@ -1427,7 +1381,7 @@ function validateBinaryMessageRoot(
if (candidate.schemaVersion !== schemaVersion) { if (candidate.schemaVersion !== schemaVersion) {
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`); throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
} }
if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) { if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) {
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`); throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
} }
@ -1465,52 +1419,8 @@ function validateRawDocumentRecord(
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`); 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);
}
function requireNonEmptyString(input: unknown, instancePath: string): void { function requireNonEmptyString(input: unknown, instancePath: string): void {
if (typeof input !== "string" || isBlank(input)) { if (typeof input !== "string" || input.trim().length === 0) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`); throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
} }
} }
@ -1526,7 +1436,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void
if (typeof input === "undefined" || input === null) { if (typeof input === "undefined" || input === null) {
return; return;
} }
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) { if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`); throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
} }
} }
@ -1625,8 +1535,6 @@ export const cultNetSchemas = {
verifySchema, verifySchema,
loginSuccessSchema, loginSuccessSchema,
errorSchema, errorSchema,
errorV1Schema,
writeAdmittedSchema,
sampleChangeNameSchema, sampleChangeNameSchema,
sampleChatSchema, sampleChatSchema,
documentPutSchema, documentPutSchema,

View file

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

View file

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

View file

@ -1,126 +0,0 @@
// 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

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

View file

@ -1,496 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { isDeepStrictEqual } from "node:util";
import Ajv2020 from "ajv/dist/2020.js";
import { decode, encode } from "@msgpack/msgpack";
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with this runtime and must reproduce the bytes, judges
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
const DIR = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "governance");
const read = (name: string) => JSON.parse(readFileSync(join(DIR, name), "utf8"));
const vote = read("vote-api-vectors.json");
const fork = read("fork-card-vectors.json");
const ajv = new Ajv2020({ allErrors: true, strict: false });
const voteSchema = read("vote-api.schema.json");
const forkSchema = read("fork-card.schema.json");
ajv.addSchema(voteSchema);
ajv.addSchema(forkSchema);
const check = (schema: { $id: string }, def: string, value: unknown): boolean => {
const validate = ajv.getSchema(`${schema.$id}#/$defs/${def}`);
assert.ok(validate, `no schema def ${def}`);
return validate(value) as boolean;
};
const hexOf = (value: unknown): string => Buffer.from(encode(value)).toString("hex").toUpperCase();
const sha = (bytes: Buffer | string): string => createHash("sha256").update(bytes).digest("hex");
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
const voteRuleErrors = (def: string, message: any): string[] =>
def === "cast" && message.ballot !== null && message.ballot.reason.length > 280 ? ["reason is over 280 UTF-16 code units"] : [];
test("vote API: every valid document validates, encodes to the vector bytes and decodes back", () => {
for (const v of vote.valid) {
assert.ok(check(voteSchema, v.def, v.message), v.name);
assert.deepEqual(voteRuleErrors(v.def, v.message), [], v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
}
});
test("vote API: every negative is refused by the schema, and its bytes are the vector's", () => {
for (const v of vote.invalid) {
assert.ok(!check(voteSchema, v.def, v.message) || voteRuleErrors(v.def, v.message).length > 0, `${v.name}: ${v.why}`);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
});
test("vote API: the commitment is SHA-256 of the canonical bytes", () => {
for (const c of vote.commitment) {
const b = c.ballot;
const bytes = Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${c.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8");
assert.equal(bytes.toString("hex").toUpperCase(), c.canonicalHex, c.name);
assert.equal(sha(bytes), c.sha256, c.name);
}
});
test("vote API: a reveal matches its commit exactly when the ballot hashes to the commitment", () => {
for (const p of vote.pairs) {
const b = p.reveal.ballot;
const digest = sha(Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${p.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8"));
assert.equal(digest === p.commit.commitment, p.matches, p.name);
assert.ok(check(voteSchema, "cast", p.commit) && check(voteSchema, "cast", p.reveal), p.name);
}
});
test("vote API: each refusal code has a vector, a status and the vector bytes", () => {
const codes = (voteSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
assert.deepEqual(vote.refusals.map((r: { message: { code: string } }) => r.message.code).sort(), [...codes].sort());
for (const r of vote.refusals) {
assert.ok(check(voteSchema, "refusal", r.message), r.name);
assert.equal(hexOf(r.message), r.messagePackHex, r.name);
assert.ok(r.httpStatus >= 400, r.name);
}
assert.ok(vote.refusals.find((r: { name: string }) => r.name === "rate_limited").message.retryAfterSeconds >= 1);
});
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
const jstr = (s: string): string => {
let out = '"';
for (const ch of s) {
const o = ch.codePointAt(0)!;
out += ch === '"' ? '\\"' : ch === "\\" ? "\\\\" : o === 8 ? "\\b" : o === 9 ? "\\t" : o === 10 ? "\\n" : o === 12 ? "\\f" : o === 13 ? "\\r"
: o < 0x20 ? "\\u" + o.toString(16).padStart(4, "0") : ch;
}
return out + '"';
};
type Option = { id: string; text: string };
type Shown = { title: string; context: string; options: Option[]; recommendation: string; depends: string[] };
type Card = Shown & { fork_id: string; leans: string | null; deciders: string[]; status: string; ruling: string | null; ruled_from: string[]; options_hash: string };
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
const nfc = (s: string): string => s.normalize("NFC");
const canonical = (c: Shown): Buffer =>
Buffer.from(`{"context":${jstr(nfc(c.context))},"depends":[${c.depends.map((d) => jstr(nfc(d))).join(",")}],"options":[${c.options.map((o) => `{"id":${jstr(nfc(o.id))},"text":${jstr(nfc(o.text))}}`).join(",")}],"recommendation":${jstr(nfc(c.recommendation))},"title":${jstr(nfc(c.title))}}`, "utf8");
const optionsHash = (c: Shown): string => sha(canonical(c));
const cardRuleErrors = (c: Card): string[] => {
const ids = c.options.map((o) => o.id);
const errors: string[] = [];
if (new Set(ids).size !== ids.length) errors.push("duplicate option ids");
if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option");
if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option");
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes");
if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling");
if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling");
if (c.options_hash !== optionsHash(c)) errors.push("options_hash is not the hash of the shown members");
return errors;
};
test("fork card: options hash is SHA-256 of the canonical JSON, computed here, and every shown member moves it", () => {
for (const h of fork.optionsHash) {
const bytes = canonical(h);
assert.equal(bytes.toString("utf8"), h.canonical, h.name);
assert.equal(bytes.toString("hex").toUpperCase(), h.canonicalHex, h.name);
assert.equal(sha(bytes), h.sha256, h.name);
}
const byName = new Map<string, string>(fork.optionsHash.map((h: { name: string; sha256: string }) => [h.name, h.sha256]));
const base = byName.get("heimdall_A_five_members")!;
assert.equal(byName.get("hidden_members_do_not_move_it"), base);
assert.equal(byName.get("heimdall_E_decomposed_equals_D"), byName.get("heimdall_D_nfc_escapes_astral"), "an NFD and an NFC spelling hash alike");
for (const moved of ["heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied", "option_text_changed",
"title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed"]) {
assert.notEqual(byName.get(moved), base, moved);
}
});
test("fork card: an answer result without counted_answer_id is refused by the schema", () => {
const result = structuredClone(fork.scenarios.flatMap((sc: any) => sc.steps).find((st: any) => st.response?.answer).response);
assert.ok(check(forkSchema, "answerResult", result));
delete result.counted_answer_id;
assert.ok(!check(forkSchema, "answerResult", result));
});
// body_sha256 is Huginn's private "same body" digest, not a wire field: compact JSON, fixed key order, null for missing, the two illegal-form keys only when present.
test("fork card: the body digest is SHA-256 of the compact JSON in the stated key order, and the fixed forms separate retracted true from false", () => {
const byName = new Map<string, any>(fork.bodyDigests.map((d: { name: string }) => [d.name, d]));
for (const d of fork.bodyDigests) {
const o: Record<string, unknown> = {};
for (const key of ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]) {
if ((key === "illegal_retraction" || key === "retracted") && d.input[key] === null) continue;
o[key] = d.input[key];
}
const json = JSON.stringify(o);
assert.equal(json, d.canonical, d.name);
assert.equal(sha(Buffer.from(json, "utf8")), d.sha256, d.name);
}
assert.ok(byName.get("plain_answer_choice_A").sha256.startsWith("8a4d5b2e"));
assert.ok(byName.get("legal_retraction").sha256.startsWith("9f807538"));
assert.ok(byName.get("legal_retraction_door_form").sha256.startsWith("84576840"));
assert.ok(byName.get("legal_retraction_door_form").input.retracts.endsWith(".n1"), "the door's form: the wire n<seq> converted to the full key");
assert.ok(byName.get("illegal_i_retraction_with_an_answer_retracted_true").sha256.startsWith("935cab1a"));
assert.ok(byName.get("illegal_iii_plain_answer_with_a_target_retracted_false").sha256.startsWith("82b24ccc"));
assert.ok(byName.get("illegal_ii_no_answer_no_target_retracted_true").sha256.startsWith("34ef86ed"));
assert.equal(new Set(fork.bodyDigests.map((d: { sha256: string }) => d.sha256)).size, fork.bodyDigests.length, "no two vectors share a digest");
});
test("fork card: mutating any shown member, an option id included, changes the hash", () => {
const card = fork.card.valid[0].message as Card;
const base = optionsHash(card);
const mutations: ((c: Card) => void)[] = [
(c) => { c.title += "x"; }, (c) => { c.context += "x"; }, (c) => { c.options[0].id += "x"; }, (c) => { c.options[0].text += "x"; },
(c) => { c.options.reverse(); }, (c) => { c.recommendation = c.options[0].id; }, (c) => { c.depends = [...c.depends, "a:b:c"]; }, (c) => { c.depends = []; },
];
for (const [i, mutate] of mutations.entries()) {
const copy = structuredClone(card);
mutate(copy);
assert.notEqual(optionsHash(copy), base, `mutation ${i}`);
}
const hidden = structuredClone(card);
hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = ["x"]; hidden.status = "closed"; hidden.ruled_from = ["n1"];
assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash");
});
test("fork card: documents validate and encode to the vector bytes", () => {
for (const v of fork.card.valid) {
assert.ok(check(forkSchema, "card", v.message), v.name);
assert.deepEqual(cardRuleErrors(v.message), [], v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
for (const v of fork[section].valid) {
assert.ok(check(forkSchema, def, v.message), v.name);
assert.deepEqual(noteRuleErrors(def, v.message), [], v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
}
}
const resultCodes = (forkSchema.$defs.deskAnswerRefused.properties.code as { enum: string[] }).enum;
assert.deepEqual(fork.results.map((r: { name: string }) => r.name).sort(), [...resultCodes].sort());
for (const v of fork.results) {
assert.ok(check(forkSchema, "answerResult", v.message), v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
assert.equal(v.message.card !== null, v.name === "stale_fork", "only a stale refusal shows the new fork");
}
for (const v of fork.unavailable) {
assert.ok(check(forkSchema, "unavailable", v.message), v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
assert.equal(v.httpStatus, 503, v.name);
}
const doorCodes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
assert.ok(!doorCodes.includes("unavailable"), "unavailable is kept apart from the refusal set");
assert.deepEqual(fork.doorRefusals.map((r: { name: string }) => r.name).sort(), [...doorCodes].sort());
for (const v of fork.doorRefusals) {
assert.ok(check(forkSchema, "refusal", v.message), v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
});
test("fork card: every negative is refused by the schema or a stated rule", () => {
for (const v of fork.card.invalid) {
assert.ok(!check(forkSchema, "card", v.message) || cardRuleErrors(v.message).length > 0, `${v.name}: ${v.why}`);
}
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
for (const v of fork[section].invalid) {
assert.ok(!check(forkSchema, def, v.message) || noteRuleErrors(def, v.message).length > 0, `${v.name}: ${v.why}`);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
}
});
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
const noteRuleErrors = (def: string, message: any): string[] => {
const note = def === "answerRequest" ? message?.answer?.note : def === "deskAnswer" ? message?.note : null;
return note != null && Buffer.byteLength(note, "utf8") > 4000 ? ["note is over 4000 UTF-8 bytes"] : [];
};
const TAILS: Record<string, string> = {
unauthenticated: "a verified session is required",
body_forbidden_field: "answerer and authority are set by the receiver",
body_unknown_field: "the body is a closed shape",
body_invalid: "the body does not satisfy gamecult.fork.answer_request.v0",
fork_unknown: "no such fork",
forbidden: "only Active members may use the desk",
request_id_conflict: "this request_id was used for a different body",
};
const REQUEST_KEYS = ["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
const hasKey = (v: unknown, names: string[]): boolean =>
typeof v === "object" && v !== null && Object.entries(v).some(([k, x]) => names.includes(k) || hasKey(x, names));
const SECONDS = (iso: string): number => Date.parse(iso);
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
function judge(start: Card, steps: { kind: string; body: Record<string, any> | null; server: Record<string, any> | null; card?: Card }[]) {
let card = start;
const answers: Record<string, any>[] = [];
const responses: unknown[] = [];
const seen = new Map<string, { body: Record<string, any>; response: unknown }>(); // (answerer, request_id) -> the first stored outcome
const counted = (): string | null => {
const hash = optionsHash(card);
const live = answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === hash
&& !answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
return live ? live.answer_id : null;
};
for (const step of steps) {
if (step.kind === "matrix_event") { responses.push(null); continue; } // rule 9: Matrix never changes a decision
if (step.kind === "card") { card = step.card!; responses.push(null); continue; }
const s = step.server!, b = step.body!;
const door = (code: string) => ({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
const store = (outcome: string, link: string | null, code: string | null) => {
const a = b.answer ?? { choice: null, note: null };
const doc = { schemaVersion: "gamecult.desk_answer.v0", answer_id: s.answer_id, fork_id: b.fork_id, outcome,
of: outcome === "Revision" ? link : null, retracts: outcome === "Retraction" ? link : outcome === "Refused" ? b.retracts : null, code: outcome === "Refused" ? code : null,
options_hash: b.options_hash, choice: a.choice, note: a.note, answered_at: b.answered_at, received_at: s.received_at,
claim_in_future: SECONDS(b.answered_at) - SECONDS(s.received_at) > 300_000, answerer: s.answerer, authority: s.authority, request_id: b.request_id };
answers.push(doc);
const stored = { schemaVersion: "gamecult.fork.answer_result.v0", answer: doc, card: code === "stale_fork" ? card : null, counted_answer_id: counted() };
seen.set(`${s.answerer}|${b.request_id}`, { body: structuredClone(b), response: structuredClone(stored) });
return stored;
};
const refuse = (code: string) => store("Refused", null, code);
const down = s.door_down === true, reached = s.stored === true; // the second door failed to answer; "stored" says whether Huginn had the request by then
const unavailable = () => ({ schemaVersion: "gamecult.fork.unavailable.v0", code: "unavailable", error: "unavailable: the outcome is not confirmed, try again with the same request_id", retryAfterSeconds: s.retry_after_seconds });
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
const replay = (stored: any) => { const copy = structuredClone(stored); if (copy.answer.code === "stale_fork") copy.card = structuredClone(card); copy.counted_answer_id = counted(); return copy; };
let response: unknown;
if (s.answerer === null) response = door("unauthenticated");
else if (s.active === false) response = door("forbidden");
else if (hasKey(b, ["answerer", "authority"])) response = door("body_forbidden_field");
else if (Object.keys(b).some((k) => !REQUEST_KEYS.includes(k)) || (b.answer && Object.keys(b.answer).some((k) => k !== "choice" && k !== "note"))) response = door("body_unknown_field");
else if (!check(forkSchema, "answerRequest", b) || noteRuleErrors("answerRequest", b).length > 0 || (!b.retracted && b.answer === null)) response = door("body_invalid");
else if (down && !reached) response = unavailable();
else if (seen.has(`${s.answerer}|${b.request_id}`)) { const prior = seen.get(`${s.answerer}|${b.request_id}`)!; response = isDeepStrictEqual(prior.body, b) ? replay(prior.response) : door("request_id_conflict"); }
else if (b.fork_id !== card.fork_id) response = door("fork_unknown");
else if (b.options_hash !== optionsHash(card)) response = refuse("stale_fork");
else if (!card.deciders.includes(s.answerer)) response = refuse("not_a_decider");
else if (card.status === "closed") response = refuse("fork_closed");
else if (b.retracted || b.retracts !== null) {
const target = answers.find((a) => a.answer_id === b.retracts && (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === b.options_hash);
if (!b.retracted || b.answer !== null || b.retracts === null || !target) response = refuse("bad_retraction");
else if (answers.some((a) => a.outcome === "Retraction" && a.retracts === target.answer_id)) response = refuse("already_retracted");
else response = store("Retraction", target.answer_id, null);
} else if (b.answer.choice !== null && !card.options.some((o) => o.id === b.answer.choice)) response = refuse("choice_invalid");
else {
const first = counted();
response = first === null ? store("Counted", null, null) : store("Revision", first, null);
}
responses.push(down && reached ? unavailable() : response);
}
return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answers } };
}
test("fork card: every scenario replays to the vector's responses and record, bytes included", () => {
for (const sc of fork.scenarios) {
assert.equal(optionsHash(sc.card), sc.optionsHash, sc.name);
assert.deepEqual(cardRuleErrors(sc.card), [], sc.name);
const { responses, record } = judge(sc.card, sc.steps);
assert.deepEqual(responses, sc.steps.map((s: { response: unknown }) => s.response), sc.name);
assert.deepEqual(record, sc.record, sc.name);
assert.ok(check(forkSchema, "record", record), sc.name);
assert.equal(hexOf(record), sc.recordHex, sc.name);
for (const s of sc.steps) if (s.response) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name);
}
});
test("fork card: a request id belongs to its account; another account's identical body gets its own answer, never the first account's outcome", () => {
let crossed = 0;
for (const sc of fork.scenarios) {
const first = new Map<string, { answerer: string; result: any }>(); // request_id -> the account that first got a stored result and what it got
for (const s of sc.steps) {
if (s.kind !== "answer" || !s.response?.answer) continue;
assert.equal(s.response.answer.answerer, s.server.answerer, `${sc.name}: a result is stamped with the account that asked`);
const prior = first.get(s.body.request_id);
if (!prior) { first.set(s.body.request_id, { answerer: s.server.answerer, result: s.response }); continue; }
if (prior.answerer === s.server.answerer) continue;
crossed++;
assert.notEqual(s.response.answer.answer_id, prior.result.answer.answer_id, `${sc.name}: its own answer id`);
assert.notDeepEqual(s.response, prior.result, `${sc.name}: the first account's outcome is never returned`);
}
}
assert.ok(crossed >= 3, "the vectors carry the cross-account cases");
});
test("fork card: a body refused at the door or naming an unknown fork is never stored", () => {
const door = new Set(["unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict"]);
let doorRefusals = 0;
for (const sc of fork.scenarios) {
for (const s of sc.steps) {
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
if (s.response?.code === "unavailable") assert.equal(sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), s.server.stored === true, `${sc.name} unavailable`);
if (s.response && door.has(s.response.code)) {
doorRefusals++;
assert.ok(!sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), `${sc.name} stored a door refusal`);
}
}
}
assert.ok(doorRefusals >= 8);
});
test("fork card: the scenarios cover every outcome and every refusal code", () => {
const seen = new Set<string>();
for (const sc of fork.scenarios) {
for (const s of sc.steps) if (s.response) seen.add(s.response.code ?? s.response.answer.code ?? s.response.answer.outcome);
for (const a of sc.record.answers) seen.add(a.outcome);
}
for (const want of ["Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork", "not_a_decider",
"fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]) {
assert.ok(seen.has(want), want);
}
});
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
const admitRuling = (cite: { authority: string; answer_id: string | null }, answers: { answer_id: string; outcome: string; authority: string }[]): boolean => {
if (cite.authority !== "Operator") return true;
const cited = answers.find((a) => a.answer_id === cite.answer_id);
return cited !== undefined && cited.outcome === "Counted" && cited.authority === "Operator";
};
test("fork card: operator authority needs a cited Counted answer record", () => {
for (const c of fork.rulingCites) {
assert.ok(check(forkSchema, "rulingCite", c.message), c.name);
assert.equal(hexOf(c.message), c.messagePackHex, c.name);
assert.equal(admitRuling(c.message, c.answers), c.admit, c.name);
}
});
// ---- transport: list, read, auth (fork-card.md "Transport") ----
const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answers: Record<string, any>[] } }): string[] => {
const errors = cardRuleErrors(e.card);
if (e.record.fork_id !== e.card.fork_id) errors.push("record of another fork");
if (e.record.options_hash !== e.card.options_hash) errors.push("record under another hash");
const counted = e.record.answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === e.card.options_hash
&& !e.record.answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
if ((counted?.answer_id ?? null) !== e.record.counted_answer_id) errors.push("counted_answer_id is not derived");
return errors;
};
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
const judgeList = (entries: { card: Card }[], ordinal: number, rq: { mind: string; status: string | null; cursor: string | null; limit: number }) => {
const rows = [...entries].sort((a, b) => (b.card as any).order - (a.card as any).order);
let want = rows.filter((e) => rq.status === null || (e.card.status === "open") === (rq.status === "open"));
if (rq.cursor !== null) want = want.filter((e) => (e.card as any).order < Number(rq.cursor!.slice(1)));
const page = want.slice(0, rq.limit);
const more = want.length > page.length && page.length > 0;
return { schemaVersion: "gamecult.fork.list.v0", mind: rq.mind, stamp: `s${ordinal}`, open_count: rows.filter((e) => e.card.status === "open").length,
recorded_count: rows.filter((e) => e.card.status === "closed").length, forks: page, next_cursor: more ? `c${(page[page.length - 1].card as any).order}` : null };
};
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
const authJudge = (route: string, token: { audience: string; scopes: string[]; active: boolean } | null, huginnDown: boolean): string => {
if (token === null || token.audience !== "bifrost-desk") return "unauthenticated";
if (!token.scopes.includes(route === "answer" ? "desk.answer" : "desk.read")) return "insufficient_scope";
if (!token.active) return "forbidden";
return huginnDown ? "unavailable" : "ok";
};
// The {mind} segment is decoded once and compared, exactly, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
type MindResult = { code: string; opened: string | null; storedIn: string | null };
const mindRoute = (config: any, route: string, segment: string, fork: string | null, token: any): MindResult => {
const auth = authJudge(route, token, false);
if (auth !== "ok") return { code: auth, opened: null, storedIn: null };
let mind: string;
try { mind = decodeURIComponent(segment); } catch { return { code: "fork_unknown", opened: null, storedIn: null }; }
if (!Object.prototype.hasOwnProperty.call(config.minds, mind)) return { code: "fork_unknown", opened: null, storedIn: null };
const opened: string = config.minds[mind];
if (route !== "list" && !config.forks[mind].includes(fork)) return { code: "fork_unknown", opened, storedIn: null };
return { code: "ok", opened, storedIn: route === "answer" ? mind : null };
};
test("fork transport: the mind segment is only compared with the configured list; an unknown mind is the unknown fork refusal", () => {
const config = fork.transport.mindRoutes;
const refusals = new Map<string, any>(fork.doorRefusals.map((r: { name: string }) => [r.name, r]));
const byName = new Map<string, MindResult>();
for (const c of config.cases) {
const got = mindRoute(config, c.route, c.mind, c.fork_id, c.token);
byName.set(c.name, got);
assert.equal(got.code, c.expect, c.name);
assert.equal(got.opened, c.opened, `${c.name}: the socket opened`);
assert.equal(got.storedIn, c.stored_in, `${c.name}: where an answer was stored`);
assert.equal(c.httpStatus, got.code === "ok" ? 200 : refusals.get(got.code).httpStatus, c.name);
if (got.code === "fork_unknown") {
const error: string = refusals.get("fork_unknown").message.error;
assert.ok(!error.includes(c.mind) && !(c.fork_id !== null && error.includes(c.fork_id)), `${c.name}: the refusal names neither the Mind nor the fork`);
}
}
for (const [a, b] of config.sameResponse) {
const x = byName.get(a)!, y = byName.get(b)!;
assert.equal(x.code, y.code);
const built = (code: string) => hexOf({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
assert.equal(built(x.code), built(y.code), `${a} and ${b} answer with the same bytes, built from the spec's rule`);
assert.equal(built(x.code), refusals.get(x.code).messagePackHex, a);
}
const all = [...byName.values()];
assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 39, "every bad segment opens nothing");
assert.equal(new Set(all.filter((v) => v.storedIn !== null).map((v) => v.storedIn)).size, 2, "an answer is stored in the route's Mind only, and both Minds take one");
});
test("fork transport: requests and entries validate and encode to the vector bytes, and every negative is refused", () => {
const t = fork.transport;
for (const [section, def] of [["listRequest", "listRequest"], ["readRequest", "readRequest"], ["entry", "entry"]] as const) {
for (const v of t[section].valid) {
assert.ok(check(forkSchema, def, v.message), v.name);
if (def === "entry") assert.deepEqual(entryRuleErrors(v.message), [], v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
for (const v of t[section].invalid) {
assert.ok(!check(forkSchema, def, v.message) || (def === "entry" && entryRuleErrors(v.message).length > 0), `${v.name}: ${v.why}`);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
}
for (const v of t.listInvalid) {
assert.equal(check(forkSchema, "list", v.message), false, `${v.name}: ${v.why}`);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
}
});
test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => {
for (const sc of fork.transport.listScenarios) {
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
let previous: { next_cursor: string | null } | null = null;
for (const step of sc.steps) {
const response = judgeList(sc.entries, sc.ordinal, step.request);
assert.deepEqual(response, step.response, sc.name);
assert.ok(check(forkSchema, "list", response), sc.name);
assert.equal(hexOf(response), step.responseHex, sc.name);
const orders = response.forks.map((f: any) => f.card.order);
assert.deepEqual(orders, [...orders].sort((a: number, b: number) => b - a), sc.name);
if (previous !== null) assert.equal(step.request.cursor, previous.next_cursor, `${sc.name}: the second request follows the first page's cursor`);
previous = response;
}
}
});
test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => {
const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
const seen = new Set<string>();
for (const c of fork.transport.authCases) {
const got = authJudge(c.route, c.token, c.huginn_down);
assert.equal(got, c.expect, c.name);
if (got !== "ok" && got !== "unavailable") assert.ok(codes.includes(got), c.name);
seen.add(`${c.route}:${got}`);
}
for (const want of ["list:insufficient_scope", "answer:insufficient_scope", "read:forbidden", "list:unavailable", "read:unavailable", "answer:unavailable", "read:unauthenticated", "answer:ok"]) assert.ok(seen.has(want), want);
});

View file

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

View file

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

View file

@ -367,10 +367,6 @@ async function bind(): Promise<Socket> {
return socket; return socket;
} }
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Wait for a datagram-driven condition rather than guessing how long loopback delivery takes on a loaded runner.
const until = async (condition: () => boolean, limitMs = 2_000) => {
for (let waited = 0; !condition() && waited < limitMs; waited += 10) await sleep(10);
};
test("server mode admits a pinned client that restarts on a new port", async () => { test("server mode admits a pinned client that restarts on a new port", async () => {
const serverSocket = await bind(); const serverSocket = await bind();
@ -389,24 +385,24 @@ test("server mode admits a pinned client that restarts on a new port", async ()
try { try {
const first = session(1); const first = session(1);
a.send(encodeRudpPacket(first.createConnect(0, enc("join"))), port, "127.0.0.1"); a.send(encodeRudpPacket(first.createConnect(0, enc("join"))), port, "127.0.0.1");
await until(() => atA.some((packet) => packet.packetType === "accept")); await sleep(100);
const accept = atA.find((packet) => packet.packetType === "accept"); const accept = atA.find((packet) => packet.packetType === "accept");
assert.ok(accept); assert.ok(accept);
first.receive(accept, 0); first.receive(accept, 0);
a.send(encodeRudpPacket(first.createAckForReceived(accept.sequence)), port, "127.0.0.1"); a.send(encodeRudpPacket(first.createAckForReceived(accept.sequence)), port, "127.0.0.1");
a.send(encodeRudpPacket(send(first, "hello")), port, "127.0.0.1"); a.send(encodeRudpPacket(send(first, "hello")), port, "127.0.0.1");
await until(() => frames.length === 1); await sleep(100);
assert.deepEqual(frames, ["hello"]); assert.deepEqual(frames, ["hello"]);
const second = session(1); // the process restarted on a new port, pinned const second = session(1); // the process restarted on a new port, pinned
b.send(encodeRudpPacket(second.createConnect(0, enc("join"))), port, "127.0.0.1"); b.send(encodeRudpPacket(second.createConnect(0, enc("join"))), port, "127.0.0.1");
await until(() => atB.some((packet) => packet.packetType === "accept")); await sleep(100);
const acceptB = atB.find((packet) => packet.packetType === "accept"); const acceptB = atB.find((packet) => packet.packetType === "accept");
assert.ok(acceptB, `the restarted client on a new port was not admitted: ${JSON.stringify(atB.map((packet) => packet.packetType))}`); assert.ok(acceptB, `the restarted client on a new port was not admitted: ${JSON.stringify(atB.map((packet) => packet.packetType))}`);
second.receive(acceptB, 1); second.receive(acceptB, 1);
assert.ok(second.connected); assert.ok(second.connected);
b.send(encodeRudpPacket(send(second, "after")), port, "127.0.0.1"); b.send(encodeRudpPacket(send(second, "after")), port, "127.0.0.1");
await until(() => frames.length === 2); await sleep(100);
assert.deepEqual(frames, ["hello", "after"]); assert.deepEqual(frames, ["hello", "after"]);
} finally { } finally {
server.close(); server.close();

View file

@ -1,339 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { decode } from "@msgpack/msgpack";
import {
CULTNET_ERROR_CODES,
CULTNET_ERROR_V1_CODES,
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
LEGACY_RESULT_WINDOW_MS,
RECEIPT_RESULT_WINDOW_MS,
WriteTracker,
cultNetSchemas,
helloAdvertisesWriteReceipts,
parseCultNetMessage,
resultWindowMs,
type CultNetErrorV1Message,
type CultNetMessage,
type CultNetWriteAdmittedMessage,
type WriteSettlement,
} from "../src";
const admitted = (messageId: string): CultNetWriteAdmittedMessage =>
({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" });
const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message =>
({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId });
const errorV0 = (): CultNetMessage =>
({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null });
test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => {
const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null);
assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort());
for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code });
assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" }));
});
test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => {
assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1");
assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null);
const { messageId: _omitted, ...withoutId } = refusedV1("w1");
assert.throws(() => parseCultNetMessage(withoutId));
assert.throws(() => parseCultNetMessage(refusedV1("")));
});
test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => {
assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false);
assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" }));
assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" }));
assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0");
});
test("write_admitted needs all five keys, a known operation and no error key", () => {
parseCultNetMessage(admitted("w1"));
parseCultNetMessage({ ...admitted("w1"), operation: "delete" });
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" }));
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" }));
const { recordKey: _omitted, ...short } = admitted("w1");
assert.throws(() => parseCultNetMessage(short));
});
test("a hello is receipt-capable only when it lists both frames", () => {
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false);
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false);
assert.equal(helloAdvertisesWriteReceipts({}), false);
const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] });
assert.equal(hello.schemaVersion, "cultnet.hello.v0");
});
test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => {
assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS);
assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS);
assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000);
assert.equal(LEGACY_RESULT_WINDOW_MS, 500);
});
function tracker(windowMs = 500) {
const told: [string, WriteSettlement][] = [];
return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) };
}
test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => {
const { t, told } = tracker();
t.send("w1", 100);
t.tick(599);
assert.equal(t.state("w1")?.outcome, "pending");
t.tick(600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: each write's window runs from its own send", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 300);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
assert.equal(t.state("w2")?.outcome, "pending");
});
test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(admitted("w1"), 500);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" });
assert.equal(told.length, 1);
});
test("tracker: a write settles once; later frames neither change it nor flip a late record", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.frame(refusedV1("w1"), 10);
t.frame(admitted("w1"), 20);
t.frame(refusedV1("w1", "message_invalid"), 30);
t.sessionEnd(40);
assert.equal(told.length, 1);
assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") });
t.send("w2", 100);
t.tick(600);
t.frame(refusedV1("w2"), 700);
t.frame(admitted("w2"), 800);
assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late");
});
test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 1);
t.frame(admitted("w1"), 5);
t.frame(refusedV1(null), 6);
assert.equal(t.state("w1")?.outcome, "admitted");
assert.equal(t.state("w2")?.outcome, "refused");
});
test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => {
const { t } = tracker();
t.send("w1", 0);
t.tick(500);
t.frame(errorV0(), 501);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
});
test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w2", 0);
t.send("w3", 400);
t.frame(admitted("w2"), 10);
t.sessionEnd(600);
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.equal(t.state("w2")?.outcome, "admitted");
assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" });
});
test("tracker: re-sending a known id is the same write and does not restart its window", () => {
const { t } = tracker();
t.send("w1", 0);
t.send("w1", 400);
t.tick(500);
assert.equal(t.state("w1")?.outcome, "unconfirmed");
});
test("tracker: a send past another write's window settles that write at once", () => {
const { t, told } = tracker();
t.send("w1", 0);
t.send("w2", 600);
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
});
test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => {
const { t } = tracker();
t.send("w1", 0);
t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1);
t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2);
assert.equal(t.state("w1")?.outcome, "pending");
});
test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => {
const { t } = tracker();
assert.equal(t.nextDeadlineMs(0), undefined);
t.send("w1", 0);
t.send("w2", 100);
assert.equal(t.nextDeadlineMs(50), 450);
assert.equal(t.nextDeadlineMs(900), 0);
t.frame(admitted("w1"), 60);
assert.equal(t.nextDeadlineMs(150), 450);
t.frame(admitted("w2"), 160);
assert.equal(t.nextDeadlineMs(170), undefined);
});
test("the replay runner passes every vector against this runtime", () => {
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
const run = spawnSync(process.execPath, [script], { encoding: "utf8" });
assert.equal(run.status, 0, run.stdout + run.stderr);
assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m);
assert.doesNotMatch(run.stdout, /^FAIL/m);
});
// A runner that skips what it cannot read passes a file it did not replay. It must fail instead.
test("the replay runner fails on an unknown event key and on a vector count the file does not declare", () => {
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
const dir = mkdtempSync(join(tmpdir(), "receipt-vectors-"));
const runWith = (edit: (vectors: any) => void) => {
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
edit(vectors);
const file = join(dir, "vectors.json");
writeFileSync(file, JSON.stringify(vectors));
return spawnSync(process.execPath, [script, file], { encoding: "utf8" });
};
const typo = runWith((v) => { v.scenarios.cases[0].events[0] = { atMs: 0, sendfailed: "w1" }; });
assert.equal(typo.status, 1, typo.stdout);
assert.match(typo.stdout, /^FAIL scenario\/receipt_admits$/m);
const dropped = runWith((v) => { v.scenarios.cases.pop(); });
assert.equal(dropped.status, 1, dropped.stdout);
assert.match(dropped.stdout, /^FAIL counts$/m);
const undeclared = runWith((v) => { v.wire.push(v.wire[0]); });
assert.equal(undeclared.status, 1, undeclared.stdout);
});
test("a write sent after the session ended settles unconfirmed session_ended at once, and the earlier receipt stands", () => {
const settled: [string, WriteSettlement][] = [];
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
tracker.send("w1", 0);
tracker.frame(admitted("w1"), 5);
tracker.sessionEnd(40);
tracker.send("w2", 50);
assert.deepEqual(settled, [["w1", { outcome: "admitted" }], ["w2", { outcome: "unconfirmed", reason: "session_ended" }]]);
});
test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => {
const settled: [string, WriteSettlement][] = [];
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
tracker.send("w1", 0);
tracker.send("w2", 0);
tracker.sendFailed("w1", 10);
assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]);
assert.equal(tracker.state("w2")?.outcome, "pending");
tracker.frame(admitted("w1"), 20);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" });
tracker.sendFailed("w2", 30);
tracker.sendFailed("w2", 31);
assert.equal(settled.length, 2, "a write settles once");
tracker.sendFailed("unknown", 40);
assert.equal(tracker.state("unknown"), undefined);
});
test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.send("w2", 500);
tracker.sendFailed("w2", 1200);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
test("a write whose own send failed settles local_send_failed even when its window has also run", () => {
const tracker = new WriteTracker({ windowMs: 1000 });
tracker.send("w1", 0);
tracker.sendFailed("w1", 1000);
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
});
// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and
// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do.
test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => {
const put = (document: Record<string, unknown>, messageId = "p1") => ({
schemaVersion: "cultnet.document_put_raw.v0", messageId,
document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document },
});
for (const blank of ["\u0085", "  ", " "]) {
assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`);
assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`);
}
for (const named of ["", "​", "\u001c", "᠎"]) {
parseCultNetMessage(put({}, named));
parseCultNetMessage(put({ recordKey: named }));
parseCultNetMessage(put({ tags: [named] }));
}
});
// `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

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

View file

@ -1,612 +0,0 @@
#!/usr/bin/env python3
"""Writes contracts/cultnet/governance/{vote-api,fork-card}-vectors.json.
The bytes are spec-authored with msgpack-python (minimal ints, str8 only past 31 bytes, map keys in the order written here);
each runtime's conformance test re-encodes the `message` and must produce the same hex. Run: python3 scripts/gen-governance-vectors.py
Needs: pip install msgpack.
"""
import hashlib, json, pathlib, unicodedata
import msgpack
OUT = pathlib.Path(__file__).resolve().parent.parent / "contracts" / "cultnet" / "governance"
def hx(m): return msgpack.packb(m, use_bin_type=True).hex().upper()
def sha(b): return hashlib.sha256(b).hexdigest()
M1 = "5b1c2e9a-3f4d-4c8e-9a61-0d7e2f4b8c13"
M2 = "a7d3f0c2-6b15-4e29-8f3a-91c4d5e6f708"
V1 = "0c9f6a4e-7d2b-4a35-b8e1-3f5a6c7d8e90"
T0 = "2026-11-12T18:00:00Z"; T1 = "2026-11-15T18:00:00Z"; T2 = "2026-11-18T18:00:00Z"
SALT = "d41d8cd98f00b204e9800998ecf8427e5f1c0a7b3e92d6a48b15c7f0e3a9d2b6"
# ---------- vote API ----------
def commitment_bytes(motion, voter, persona, rnd, candidate, reason, salt):
return f"gamecult.sealed_ballot.v0\n{motion}\n{voter}\n{persona}\n{rnd}\n{candidate}\n{reason}\n{salt}".encode("utf-8")
def ballot(motion=M1, rnd=1, candidate="Adopt the ledger", reason="It keeps the books honest.", salt=SALT, persona=""):
return {"schema": "gamecult.sealed_ballot.v0", "motionId": motion, "round": rnd, "candidate": candidate,
"reason": reason, "salt": salt, "persona": persona}
def digest(b, voter=V1):
return sha(commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"]))
B_MEMBER = ballot()
B_PERSONA = ballot(persona="libby", candidate="Defer to round two", reason="The sealed window is too short to weigh both.")
C_MEMBER = digest(B_MEMBER); C_PERSONA = digest(B_PERSONA)
def receipt(commitment, revision=1, revealed=False, motion=M1):
return {"schemaVersion": "gamecult.vote.receipt.v0", "motionId": motion, "voterUserAccountId": V1,
"commitment": commitment, "revision": revision, "committedAtUtc": "2026-11-12T19:04:11.1234567Z", "revealed": revealed}
def motion(phase, your=None, mid=M1, high=False):
return {"schemaVersion": "gamecult.vote.motion.v0", "id": mid, "title": "Ledger or lantern", "summary": "Pick one.",
"round": 1, "priorMotionId": None, "ballotSchema": "gamecult.sealed_ballot.v0", "phase": phase,
"candidates": ["Adopt the ledger", "Defer to round two"], "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high,
"yourVoterId": V1, "yourCommitment": your, "announcement": None}
def cast_commit(c, motion=M1): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": motion, "verb": "commit", "commitment": c, "ballot": None}
def cast_reveal(b): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": b["motionId"], "verb": "reveal", "commitment": None, "ballot": b}
def refusal(code, error, retry=None): return {"schemaVersion": "gamecult.vote.refusal.v0", "code": code, "error": error, "retryAfterSeconds": retry}
def item(i, title, phase, high=False): return {"id": i, "title": title, "round": 1, "phase": phase, "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high}
valid = [
("motion_list_request_all", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": None}),
("motion_list_request_reveal", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": "Reveal"}),
("motion_list_two", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [item(M1, "Ledger or lantern", "Commit"), item(M2, "Second thoughts", "Closed", True)]}),
("motion_list_empty", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": []}),
("motion_read_request", "motionReadRequest", {"schemaVersion": "gamecult.vote.motion_read_request.v0", "motionId": M1}),
("motion_commit_phase_no_receipt", "motion", motion("Commit")),
("motion_high_stakes_commit_phase", "motion", motion("Commit", high=True)),
("motion_reveal_phase_committed", "motion", motion("Reveal", receipt(C_MEMBER))),
("motion_closed_revealed", "motion", motion("Closed", receipt(C_MEMBER, 2, True))),
("cast_reveal_reason_140_astral_280_units", "cast", cast_reveal(ballot(reason="\U0001F600" * 140))),
("cast_commit", "cast", cast_commit(C_MEMBER)),
("cast_reveal_member", "cast", cast_reveal(B_MEMBER)),
("cast_reveal_persona", "cast", cast_reveal(B_PERSONA)),
("receipt_committed", "receipt", receipt(C_MEMBER)),
("receipt_revealed_echoes_no_ballot", "receipt", receipt(C_MEMBER, 2, True)),
]
refusals = [
("unauthenticated", 401, "no bearer, or one Heimdall does not know", refusal("unauthenticated", "A bearer token is required.")),
("reauthentication_required", 401, "a commit on a high-stakes motion with no sign-in fresh enough (Bifrost PR #37, D2); the voter signs in again at the top level and retries", refusal("reauthentication_required", "Sign in again to commit on this motion.")),
("forbidden", 403, "authenticated, not an Active member, or a Mind key used on a motion route it may not call", refusal("forbidden", "Only Active members may vote.")),
("not_found", 404, "unknown motion id, a standard (non-sealed) motion id, or reveal without a prior commit", refusal("not_found", "No such sealed-ballot motion.")),
("wrong_phase", 409, "commit outside Commit, reveal outside Reveal", refusal("wrong_phase", "The commit window is not open.")),
("contended", 409, "reveal raced the close fence; send it again", refusal("contended", "The reveal is contended; send it again.")),
("invalid", 400, "body fails vote-api.schema.json or an in-document rule (untrimmed candidate or reason, candidate not in the motion, round or motionId mismatch, persona wrong for the credential)", refusal("invalid", "The cast does not satisfy gamecult.vote.cast.v0.")),
("commitment_mismatch", 400, "reveal whose canonical bytes do not hash to the stored commitment", refusal("commitment_mismatch", "The ballot does not match the stored commitment.")),
("payload_too_large", 413, "body over 4096 bytes", refusal("payload_too_large", "The body exceeds 4096 bytes.")),
("held", 503, "the Ratatoskr voting brake is set (Bifrost PR #37, D5): no cast is read or stored while it is", refusal("held", "Voting is held.")),
("rate_limited", 429, "more than the per-account cast rate; Retry-After mirrors retryAfterSeconds", refusal("rate_limited", "Too many casts.", 30)),
]
def tweak(d, **kw):
d = json.loads(json.dumps(d));
for k, v in kw.items():
if k.startswith("b_"): d["ballot"][k[2:]] = v
else: d[k] = v
return d
rev = cast_reveal(B_MEMBER)
invalid = [
("cast_commit_carries_a_ballot", "cast", tweak(cast_commit(C_MEMBER), ballot=B_MEMBER), "a commit names exactly the commitment; a ballot beside it would put content on the wire before the reveal"),
("cast_reveal_carries_a_commitment", "cast", tweak(rev, commitment=C_MEMBER), "a reveal names exactly the ballot"),
("cast_commit_without_commitment", "cast", tweak(cast_commit(C_MEMBER), commitment=None), "commit needs its hash"),
("cast_verb_unknown", "cast", tweak(rev, verb="recast"), "verb is commit or reveal"),
("cast_commitment_uppercase", "cast", cast_commit(C_MEMBER.upper()), "commitment is lowercase hex"),
("cast_commitment_63_chars", "cast", cast_commit(C_MEMBER[:63]), "commitment is 64 hex characters"),
("cast_unknown_key", "cast", tweak(cast_commit(C_MEMBER), note="hi"), "closed document"),
("cast_motion_id_uppercase", "cast", cast_commit(C_MEMBER, M1.upper()), "ids are lowercase D-format uuids"),
("ballot_reason_empty", "cast", tweak(rev, b_reason=""), "reason is 1 to 280 characters"),
("ballot_reason_281", "cast", tweak(rev, b_reason="x" * 281), "reason is at most 280 characters"),
("ballot_reason_141_astral_282_units", "cast", tweak(rev, b_reason="\U0001F600" * 141), "reason is at most 280 UTF-16 code units (vote-api.md); the schema counts code points and accepts this, so a rule refuses it"),
("ballot_reason_multiline", "cast", tweak(rev, b_reason="one\ntwo"), "reason is a single line"),
("ballot_reason_leading_space", "cast", tweak(rev, b_reason=" because"), "the contract refuses untrimmed text; it never trims for the sender, so the hashed bytes are the sent bytes"),
("ballot_candidate_trailing_nbsp", "cast", tweak(rev, b_candidate="Adopt the ledger "), "NO-BREAK SPACE is White_Space; trimming is judged by the same list as write-receipt Required fields"),
("ballot_salt_31_hex", "cast", tweak(rev, b_salt=SALT[:31]), "salt is 32 to 128 lowercase hex characters"),
("ballot_salt_uppercase", "cast", tweak(rev, b_salt=SALT.upper()), "salt is lowercase hex"),
("ballot_schema_wrong", "cast", tweak(rev, b_schema="gamecult.sealed_ballot.v1"), "ballot schema is gamecult.sealed_ballot.v0"),
("ballot_persona_with_space", "cast", tweak(rev, b_persona="my mind"), "persona is printable ASCII without space, at most 64"),
("ballot_round_zero", "cast", tweak(rev, b_round=0), "rounds start at 1"),
("receipt_echoes_candidate", "receipt", dict(receipt(C_MEMBER, 2, True), candidate="Adopt the ledger"), "sealed casts never echo ballot content; the receipt is closed"),
("receipt_echoes_salt", "receipt", dict(receipt(C_MEMBER, 2, True), salt=SALT), "the salt never comes back before close"),
("receipt_revision_zero", "receipt", receipt(C_MEMBER, 0), "revision starts at 1"),
("receipt_time_without_zone", "receipt", dict(receipt(C_MEMBER), committedAtUtc="2026-11-12T19:04:11"), "instants are UTC with a Z"),
("motion_one_candidate", "motion", dict(motion("Commit"), candidates=["Only"]), "a motion has two or more candidates"),
("motion_phase_unknown", "motion", dict(motion("Commit"), phase="Open"), "phase is Commit, Reveal or Closed"),
("motion_high_stakes_not_boolean", "motion", dict(motion("Commit"), highStakes="yes"), "highStakes is a boolean, fixed at open"),
("motion_without_high_stakes", "motion", {k: v for k, v in motion("Commit").items() if k != "highStakes"}, "map-mode writes every key; a motion states whether it is high-stakes"),
("motion_list_item_without_high_stakes", "motionListItem", {k: v for k, v in item(M1, "x", "Commit").items() if k != "highStakes"}, "every list item states whether the motion is high-stakes"),
("motion_leaks_tally", "motion", dict(motion("Reveal"), tally=[]), "mid-vote a client sees no tallies; the document is closed"),
("motion_list_item_without_phase", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [{k: v for k, v in item(M1, "x", "Commit").items() if k != "phase"}]}, "every item names its phase"),
("refusal_code_unknown", "refusal", refusal("teapot", "no"), "the code set is closed"),
("refusal_without_error", "refusal", dict(refusal("invalid", "x"), error=""), "error is non-empty"),
]
pairs = [
{"name": "member_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": True},
{"name": "persona_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(B_PERSONA), "matches": True},
{"name": "reveal_with_changed_reason_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(ballot(reason="It keeps the books honest!")), "matches": False, "refusal": "commitment_mismatch"},
{"name": "reveal_under_another_voter_does_not_match", "voterUserAccountId": M2, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": False, "refusal": "commitment_mismatch"},
{"name": "persona_dropped_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(ballot(persona="", candidate=B_PERSONA["candidate"], reason=B_PERSONA["reason"])), "matches": False, "refusal": "commitment_mismatch"},
]
commitments = []
for name, b, voter in [("member", B_MEMBER, V1), ("persona", B_PERSONA, V1), ("non_ascii", ballot(candidate="Café ☕", reason="naïve – but fine"), V1),
("decomposed_e_is_another_ballot", ballot(candidate="Café ☕", reason="naïve – but fine"), V1)]:
cb = commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"])
commitments.append({"name": name, "voterUserAccountId": voter, "ballot": b, "canonicalHex": cb.hex().upper(), "sha256": sha(cb)})
vote = {
"provenance": "Spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. Commitment hashes follow vote-api.md 'Commitment'.",
"commitment": commitments,
"valid": [{"name": n, "def": d, "message": m, "messagePackHex": hx(m)} for n, d, m in valid],
"invalid": [{"name": n, "def": d, "message": m, "why": w, "messagePackHex": hx(m)} for n, d, m, w in invalid],
"pairs": pairs,
"refusals": [{"name": n, "httpStatus": s, "trigger": t, "message": m, "messagePackHex": hx(m)} for n, s, t, m in refusals],
}
(OUT / "vote-api-vectors.json").write_text(json.dumps(vote, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
# ---------- fork card ----------
# Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), sections 1.1 to 1.5 and 1.3a. The seven hash
# vectors A to G below are that file's, with its expected digests asserted.
def jstr(x):
out = ['"']
for ch in x:
o = ord(ch)
if ch == '"': out.append('\\"')
elif ch == "\\": out.append("\\\\")
elif ch == "\b": out.append("\\b")
elif ch == "\t": out.append("\\t")
elif ch == "\n": out.append("\\n")
elif ch == "\f": out.append("\\f")
elif ch == "\r": out.append("\\r")
elif o < 0x20: out.append("\\u%04x" % o)
else: out.append(ch)
out.append('"')
return "".join(out)
def nfc(x): return unicodedata.normalize("NFC", x)
def canonical_json(title, context, options, recommendation, depends):
"""RFC 8785 over {context, depends, options:[{id,text}], recommendation, title} after NFC on every string; keys sorted; no numbers. (None is accepted only to check Heimdall's null digests.)"""
opts = ",".join('{"id":%s,"text":%s}' % (jstr(nfc(o["id"])), jstr(nfc(o["text"]))) for o in options)
dep = ",".join(jstr(nfc(d)) for d in depends)
rec = "null" if recommendation is None else jstr(nfc(recommendation))
return ('{"context":%s,"depends":[%s],"options":[%s],"recommendation":%s,"title":%s}' % (jstr(nfc(context)), dep, opts, rec, jstr(nfc(title)))).encode("utf-8")
OP = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f"; ST = "11111111-2222-4333-8444-555555555555"; HM = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a"
FID = "gamecult-chat:question:slot-mark-consent"
OPT = [{"id": "yes", "text": "Share it."}, {"id": "no", "text": "Keep it local."}]
DEP = ["gamecult-chat:question:file-zone-audience"]
def chash(c): return sha(canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"]))
def card(**kw):
c = {"schemaVersion": "gamecult.fork.card.v0", "fork_id": FID, "title": "Slot mark consent", "context": "Should a slot mark be shared?",
"options": OPT, "recommendation": "no", "depends": DEP, "leans": None, "flag": None,
"raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "deciders": [OP],
"status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""}
c.update(kw); c["options_hash"] = chash(c); return c
CARD = card(); H = CARD["options_hash"]
hashes = []
CLAIMED = {"A": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "B": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc",
"C": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5", "D": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272",
"E": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272", "F": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40",
"G": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"}
def hv(name, c, heimdall=None):
b = canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"])
if heimdall: print("heimdall", heimdall, "same" if CLAIMED[heimdall] == sha(b) else f"DIFFERS mine={sha(b)} his={CLAIMED[heimdall]}")
hashes.append({"name": name, "heimdall": heimdall, "title": c["title"], "context": c["context"], "options": c["options"],
"recommendation": c["recommendation"], "depends": c["depends"], "canonical": b.decode("utf-8"),
"canonicalHex": b.hex().upper(), "sha256": sha(b)})
CD = dict(title="Caf\u00e9 \u201cdesk\u201d", context="Line one\nLine two \u2014 \U0001F600", options=[{"id": "a", "text": "x"}, {"id": "b", "text": "y"}], recommendation="a", depends=[])
hv("heimdall_A_five_members", CARD, "A")
hv("heimdall_B_recommendation_changed", card(recommendation="yes"), "B")
hv("heimdall_C_option_order_changed", card(options=[OPT[1], OPT[0]]), "C")
hv("heimdall_D_nfc_escapes_astral", card(**CD), "D")
hv("heimdall_E_decomposed_equals_D", card(**dict(CD, title="Cafe\u0301 \u201cdesk\u201d")), "E")
hv("heimdall_F_option_id_changed", card(options=[{"id": "share", "text": "Share it."}, OPT[1]]), "F")
hv("heimdall_G_depends_emptied", card(depends=[]), "G")
hv("option_text_changed", card(options=[dict(OPT[0], text="Share it!"), OPT[1]]))
hv("title_changed", card(title="Slot mark consent?"))
hv("context_changed", card(context="Should slot marks be shared?"))
hv("depends_order_changed", card(depends=DEP + ["x:question:y"]))
hv("depends_order_changed_reversed", card(depends=["x:question:y"] + DEP))
hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[OP, HM], status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, fork_id="other:question:id"))
hv("control_characters_and_markup_are_escaped_minimally", card(title='q " b \\ t \t n \n bell \u0007 del \u007f', context="</script> \u2028 &"))
hv("astral_option_id_and_text", card(options=[{"id": "\U0001F600", "text": "\U0001D11E"}, OPT[1]]))
assert [h["sha256"] for h in hashes if h["heimdall"] == "D"] == [h["sha256"] for h in hashes if h["heimdall"] == "E"]
assert len({h["sha256"] for h in hashes if h["name"] in ("heimdall_A_five_members", "hidden_members_do_not_move_it")}) == 1
RID = [0]
def next_rid():
RID[0] += 1; n = RID[0]
return f"{n:08x}-0000-4000-8000-{n:012x}"
def body(choice=None, note=None, h=None, at="2026-11-12T19:02:00Z", fork=FID, retracts=None, answer="auto", rid=None):
a = {"choice": choice, "note": note} if answer == "auto" else answer
if answer == "auto" and choice is None and note is None: a = None
return {"schemaVersion": "gamecult.fork.answer_request.v0", "fork_id": fork, "options_hash": h or H, "answer": a,
"retracted": retracts is not None, "retracts": retracts, "answered_at": at, "request_id": rid or next_rid()}
def retraction(target, **kw): return body(retracts=target, **kw)
NOTE = "Keep it local: a slot mark is the author's call, and sharing it is a separate act."
def srv(aid, who, at, auth="Operator"): return {"answer_id": aid, "answerer": who, "authority": auth, "received_at": at}
TAIL = {"unauthenticated": "a verified session is required", "body_forbidden_field": "answerer and authority are set by the receiver", "body_unknown_field": "the body is a closed shape",
"body_invalid": "the body does not satisfy gamecult.fork.answer_request.v0", "fork_unknown": "no such fork",
"insufficient_scope": "the token lacks the scope this route needs", "forbidden": "only Active members may use the desk", "request_id_conflict": "this request_id was used for a different body", "request_invalid": "the request does not satisfy its document"}
DOOR = set(TAIL)
def refusal_doc(code): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": code, "error": f"{code}: {TAIL[code]}"}
def unavailable_doc(retry=30): return {"schemaVersion": "gamecult.fork.unavailable.v0", "code": "unavailable", "error": "unavailable: the outcome is not confirmed, try again with the same request_id", "retryAfterSeconds": retry}
def future(a, r):
import datetime as dt
p = lambda x: dt.datetime.fromisoformat(x.replace("Z", "+00:00"))
return (p(a) - p(r)).total_seconds() > 300
def da(outcome, link, code, b, server):
a = b["answer"] or {"choice": None, "note": None}
return {"schemaVersion": "gamecult.desk_answer.v0", "answer_id": server["answer_id"], "fork_id": b["fork_id"], "outcome": outcome,
"of": link if outcome == "Revision" else None, "retracts": link if outcome == "Retraction" else (b["retracts"] if outcome == "Refused" else None), "code": code if outcome == "Refused" else None,
"options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"],
"claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]}
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id
def record(counted, answers, h=H, fork=FID): return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answers": answers}
T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z"
def step(kind, body_=None, server=None, response=None, **kw):
d = {"kind": kind, "body": body_, "server": server, "response": response}; d.update(kw); return d
def ok(b, server, outcome, of):
a = da(outcome, of, None, b, server); return step("answer", b, server, result(a)), a
def rf(b, server, code, c=None):
a = da("Refused", None, code, b, server); return step("answer", b, server, result(a, c)), a
def door(b, server, code): return step("answer", b, server, refusal_doc(code)), None
def sc(name, c, items, counted, h=None):
# answer_result.counted_answer_id: the counted answer after the step, derived from the answers stored so far under the card's current hash
steps, answers, cur = [], [], c["options_hash"]
def live(): return next((a["answer_id"] for a in answers if a["outcome"] in ("Counted", "Revision") and a["options_hash"] == cur
and not any(r["outcome"] == "Retraction" and r["retracts"] == a["answer_id"] for r in answers)), None)
for i in items:
st = i[0] if isinstance(i, tuple) else i
if isinstance(i, tuple) and i[1] is not None: answers.append(i[1])
if st["kind"] == "card": cur = st["card"]["options_hash"]
if st["kind"] == "answer" and (st["response"] or {}).get("schemaVersion") == "gamecult.fork.answer_result.v0":
st = dict(st, response=dict(st["response"], counted_answer_id=live()))
steps.append(st)
assert live() == counted, (name, live(), counted)
return (name, c, steps, record(counted, answers, h or c["options_hash"]))
x1 = body("no", NOTE); sx1 = srv("n1", OP, T(2))
x2 = body("yes", "On reflection, share it.", at="2026-11-12T19:06:00Z"); sx2 = srv("n2", OP, T(6))
y1 = body("yes", "Share it, the audience is settled.", at="2026-11-12T19:05:00Z"); sy1 = srv("n3", HM, T(5), auth="Steward")
z1 = body("yes", "Share, please."); sz = srv("n4", ST, T(3), auth="Member")
pick = body("no", None); words = body(None, NOTE)
STALE_H = chash(card(recommendation="yes"))
def stale_body(**kw): return body("no", NOTE, h=STALE_H, **kw)
FORKC = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")
RULED = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1", ruled_from=["n1"])
Y_CARD = card(deciders=[OP, HM])
fork_edit = card(title="Slot mark consent (revised)"); H2 = fork_edit["options_hash"]
RET = lambda t: retraction(t, at="2026-11-12T19:09:00Z")
def sr(aid, who=OP, m=9, auth="Operator"): return srv(aid, who, T(m), auth)
MATRIX = [step("matrix_event", None, None, None, matrix_kind=k, choice=c) for k, c in (("m.replace", "yes"), ("m.redaction", None), ("m.reaction", "yes"))]
X1 = ok(x1, sx1, "Counted", None)
REPLAY1 = (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)
LOST = [(step("answer", x1, dict(sx1, door_down=True, stored=True, retry_after_seconds=30), unavailable_doc(30)), X1[1])]
STALE_BODY = stale_body(); STALE_RF = rf(STALE_BODY, sx1, "stale_fork", c=CARD)
OFFLIST = rf(x1, srv("n1", ST, T(2), "Member"), "not_a_decider")
OFFLIST_REPLAY = (step("answer", x1, srv("n9", ST, T(4), "Member"), OFFLIST[0]["response"]), None)
scen = [
sc("operator_answers_and_is_counted", CARD, [X1], "n1"),
sc("choice_alone_is_counted", CARD, [ok(pick, sx1, "Counted", None)], "n1"),
sc("note_alone_is_counted_a_clear_pick", CARD, [ok(words, sx1, "Counted", None)], "n1"),
sc("V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", CARD, [door(dict(x1, answerer="x"), sx1, "body_forbidden_field")], None),
sc("V1_authority_nested_in_answer_is_refused", CARD, [door(dict(x1, answer={"choice": "no", "note": None, "authority": "Operator"}), sx1, "body_forbidden_field")], None),
sc("V1_both_are_refused_not_stripped", CARD, [door(dict(x1, answerer=OP, authority="Operator"), sx1, "body_forbidden_field")], None),
sc("M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", CARD, [door(dict(x1, deciders=[ST]), sx1, "body_unknown_field")], None),
sc("M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", CARD, [door(dict(x1, mind="nibu"), sx1, "body_unknown_field")], None),
sc("an_unknown_key_is_refused_the_body_is_closed", CARD, [door(dict(x1, mood="happy"), sx1, "body_unknown_field")], None),
sc("an_answer_with_neither_choice_nor_note_is_invalid", CARD, [door(body(answer={"choice": None, "note": None}), sx1, "body_invalid")], None),
sc("a_non_retraction_without_an_answer_is_invalid", CARD, [door(body(answer=None), sx1, "body_invalid")], None),
sc("unavailable_before_huginn_stored_nothing_is_retried_into_a_Counted_answer", CARD, [step("answer", x1, dict(sx1, answer_id="n9", door_down=True, retry_after_seconds=30), unavailable_doc(30)), ok(x1, sx1, "Counted", None)], "n1"),
sc("unavailable_after_huginn_stored_the_retry_with_the_same_request_id_returns_the_stored_outcome", CARD, LOST + [REPLAY1], "n1"),
sc("a_retry_never_adds_a_Revision", CARD, [X1, REPLAY1], "n1"),
sc("Q1_a_replayed_stale_fork_returns_the_CURRENT_card_and_stores_nothing_new", CARD, [STALE_RF, step("card", None, None, None, card=fork_edit),
(step("answer", STALE_BODY, dict(sx1, answer_id="n9"), result(STALE_RF[1], fork_edit)), None)], None, H2),
sc("V13_only_answered_at_changed_is_request_id_conflict", CARD, [X1, door(dict(x1, answered_at="2026-11-12T19:03:00Z"), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
sc("V14_an_identical_body_from_another_account_gets_its_own_Counted_and_never_the_first_accounts_outcome", Y_CARD, [OFFLIST, ok(x1, srv("n2", HM, T(3), "Steward"), "Counted", None), OFFLIST_REPLAY], "n2"),
sc("V14_an_identical_body_from_another_decider_is_its_own_Revision_and_the_first_retry_still_returns_n1", Y_CARD, [X1, ok(x1, sy1, "Revision", "n1"), (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)], "n1"),
sc("V16_Q5_a_forbidden_request_binds_no_request_id", CARD, [door(x1, dict(sx1, answer_id="n9", active=False), "forbidden"), ok(x1, sx1, "Counted", None)], "n1"),
sc("V17_Q6_a_body_refused_at_the_door_binds_no_request_id", CARD, [door(dict(x1, mood="happy"), dict(sx1, answer_id="n9"), "body_unknown_field"), ok(x1, sx1, "Counted", None)], "n1"),
sc("V17_Q6_an_unknown_fork_binds_no_request_id", CARD, [door(dict(x1, fork_id="gamecult-chat:question:nope"), dict(sx1, answer_id="n9"), "fork_unknown"), ok(x1, sx1, "Counted", None)], "n1"),
sc("the_same_request_id_with_another_body_is_request_id_conflict", CARD, [X1, door(dict(x1, answer={"choice": "yes", "note": None}), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
sc("the_same_request_id_from_another_account_is_another_request", Y_CARD, [X1, ok(dict(y1, request_id=x1["request_id"]), sy1, "Revision", "n1")], "n1"),
sc("a_non_active_member_is_forbidden_before_the_body_is_read", CARD, [door(dict(x1, answerer="x"), dict(sx1, active=False), "forbidden"), door(x1, dict(sx1, active=False), "forbidden"),
door(dict(x1, mood="happy"), dict(sx1, active=False, door_down=True, retry_after_seconds=30), "forbidden")], None),
sc("no_session_is_unauthenticated", CARD, [door(x1, srv("n1", None, T(2), None), "unauthenticated")], None),
sc("an_unknown_fork_is_refused_at_the_door", CARD, [door(body("no", NOTE, fork="gamecult-chat:question:nope"), sx1, "fork_unknown")], None),
sc("V2_stale_hash_is_refused_not_counted_and_stored_as_Refused", CARD, [STALE_RF], None),
sc("V2_a_stale_answer_then_a_fresh_one_the_fresh_one_is_counted", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), ok(x1, sx2, "Counted", None)], "n2"),
sc("V3_an_answer_off_the_decider_list_is_Refused_not_a_decider_and_stored", CARD, [rf(z1, sz, "not_a_decider")], None),
sc("a_non_decider_with_a_stale_hash_is_stale_first", CARD, [rf(dict(z1, options_hash=STALE_H), sz, "stale_fork", c=CARD)], None),
sc("V4_a_closed_fork_refuses_and_stores_the_refusal", FORKC, [rf(x1, sx1, "fork_closed")], None),
sc("V5_second_answer_from_one_decider_is_a_Revision_the_first_stays_counted", CARD, [X1, ok(x2, sx2, "Revision", "n1")], "n1"),
sc("V5_receiver_order_decides_not_answered_at", CARD, [ok(x2, srv("n1", OP, T(2)), "Counted", None), ok(x1, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
sc("V5_reversed_arrival_reverses_the_result", CARD, [ok(x1, srv("n1", OP, T(2)), "Counted", None), ok(x2, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
sc("a_second_decider_answer_is_recorded_as_a_Revision", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1")], "n1"),
sc("a_claim_exactly_five_minutes_ahead_is_not_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05Z"), sx1, "Counted", None)], "n1"),
sc("a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05.500Z"), sx1, "Counted", None)], "n1"),
sc("a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:09:00Z"), sx1, "Counted", None)], "n1"),
sc("V6_a_matrix_edit_redaction_or_reaction_changes_nothing", CARD, [X1] + MATRIX, "n1"),
sc("V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", CARD, [rf(body("maybe", None), sx1, "choice_invalid")], None),
sc("a_changed_card_stops_the_old_answers_counting_and_a_new_answer_is_counted", CARD,
[X1, step("card", None, None, None, card=fork_edit), ok(body("no", NOTE, h=H2), sx2, "Counted", None)], "n2", H2),
sc("a_changed_card_leaves_no_counted_answer_until_one_arrives", CARD, [X1, step("card", None, None, None, card=fork_edit)], None, H2),
sc("V8_retraction_with_an_answer_is_bad", CARD, [X1, rf(dict(retraction("n1"), answer={"choice": "no", "note": None}), sr("n9"), "bad_retraction")], "n1"),
sc("V8_retraction_without_retracts_is_bad", CARD, [X1, rf(dict(retraction("n1"), retracts=None), sr("n9"), "bad_retraction")], "n1"),
sc("V8_retracts_on_a_plain_answer_is_bad", CARD, [X1, rf(dict(x2, retracts="n1"), sr("n9"), "bad_retraction")], "n1"),
sc("V8_retraction_naming_an_unknown_answer_is_bad", CARD, [X1, rf(retraction("n99"), sr("n9"), "bad_retraction")], "n1"),
sc("V8_retraction_naming_a_Refused_answer_is_bad", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), rf(retraction("n1"), sr("n9"), "bad_retraction")], None),
sc("V8_retracting_twice_is_already_retracted", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), rf(RET("n1"), sr("n9", m=10), "already_retracted")], None),
sc("V8_retraction_under_a_stale_hash_is_stale_fork", CARD, [X1, rf(retraction("n1", h=STALE_H), sr("n9"), "stale_fork", c=CARD)], "n1"),
sc("V9_another_decider_retracts_and_the_answer_stops_counting", Y_CARD, [X1, ok(RET("n1"), sr("n7", HM, 9, "Steward"), "Retraction", "n1")], None),
sc("V9_an_account_off_the_list_cannot_retract", CARD, [X1, rf(RET("n1"), sr("n6", ST, 9, "Member"), "not_a_decider")], "n1"),
sc("V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed", CARD, [X1, step("card", None, None, None, card=RULED), rf(RET("n1"), sr("n9"), "fork_closed")], "n1"),
sc("V11_retracting_the_counted_answer_makes_the_next_decider_answer_counted", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n1"), sr("n8"), "Retraction", "n1")], "n3"),
sc("V11_retracting_an_answer_that_was_not_counted_changes_nothing", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n3"), sr("n8"), "Retraction", "n3")], "n1"),
sc("after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), ok(x2, sx2, "Counted", None)], "n2"),
]
# The three illegal forms (R1) under fixed request ids (a fresh next_rid() here would move every later vector). A and C differ only in `retracted`.
IL_A = dict(retraction("n1", rid="0000a001-0000-4000-8000-00000000a001"), answer={"choice": "no", "note": None}) # a retraction carrying an answer
IL_B = dict(retraction("n1", rid="0000b001-0000-4000-8000-00000000b001"), retracts=None) # a retraction naming no target
IL_C = dict(body("no", None, rid="0000a001-0000-4000-8000-00000000a001"), retracts="n1") # a plain answer carrying a target
assert {k: v for k, v in IL_A.items() if k != "retracted"} == {k: v for k, v in IL_C.items() if k != "retracted"} and IL_A["retracted"] and not IL_C["retracted"]
def replayed(first, server): return (step("answer", first[0]["body"], dict(server, answer_id="n9"), first[0]["response"]), None)
for _n, _f in (("a_retraction_with_an_answer", IL_A), ("a_retraction_with_no_target", IL_B), ("a_plain_answer_with_a_target", IL_C)):
_r = rf(_f, sr("n2"), "bad_retraction")
scen.append(sc(f"R1_{_n}_is_a_stored_bad_retraction_and_a_retry_replays_it", CARD, [X1, _r, replayed(_r, sr("n2"))], "n1"))
scen += [
sc("a_replay_returns_the_counted_answer_id_current_at_the_retry_not_the_one_stored", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), REPLAY1], None),
sc("R1_retracted_true_with_an_answer_and_no_target_is_a_stored_bad_retraction", CARD, [X1, rf(dict(IL_A, retracts=None), sr("n2"), "bad_retraction")], "n1"),
sc("R1_retracted_true_then_false_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_A, sr("n2"), "bad_retraction"), door(IL_C, sr("n9"), "request_id_conflict")], "n1"),
sc("R1_retracted_false_then_true_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_C, sr("n2"), "bad_retraction"), door(IL_A, sr("n9"), "request_id_conflict")], "n1"),
sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"),
]
REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400,
"stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409}
STORED_REFUSALS = []
for reason in REASON_CODES:
a = da("Refused", None, reason, x1, sx1)
STORED_REFUSALS.append({"name": reason, "httpStatus": HTTP[reason], "message": result(a, CARD if reason == "stale_fork" else None)})
# ruling citation: authority Operator must cite a Counted answer record whose answerer holds Operator authority
CNT = da("Counted", None, None, x1, sx1); REV = da("Revision", "n1", None, x2, sx2)
REF = da("Refused", None, "fork_closed", x1, srv("n5", OP, T(7))); NONOP = da("Counted", None, None, z1, sz)
def cite(authority, ans, ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1"):
return {"schemaVersion": "gamecult.fork.ruling_cite.v0", "ruling_id": ruling, "authority": authority, "answer_id": ans}
cites = [
{"name": "operator_cites_a_counted_operator_answer", "answers": [CNT], "message": cite("Operator", "n1"), "admit": True},
{"name": "operator_cites_nothing", "answers": [CNT], "message": cite("Operator", None), "admit": False},
{"name": "operator_cites_a_revision", "answers": [CNT, REV], "message": cite("Operator", "n2"), "admit": False},
{"name": "operator_cites_a_refused_answer", "answers": [REF], "message": cite("Operator", "n5"), "admit": False},
{"name": "operator_cites_a_counted_answer_by_a_non_operator", "answers": [NONOP], "message": cite("Operator", "n4"), "admit": False},
{"name": "operator_cites_an_answer_that_does_not_exist", "answers": [CNT], "message": cite("Operator", "n99"), "admit": False},
{"name": "steward_needs_no_citation", "answers": [], "message": cite("Steward", None), "admit": True},
]
for cz in cites: cz["messagePackHex"] = hx(cz["message"])
# ---------- transport: list, read, auth ----------
def fcard(n, status="open", **kw):
c = card(fork_id=f"gamecult-chat:question:q{n}", title=f"Fork {n}", order=n, status=status, **kw)
return c
def fbody(c, choice, aid_note):
return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"])
def fentry(c, answers=(), counted=None):
return {"schemaVersion": "gamecult.fork.entry.v0", "card": c, "record": record(counted, list(answers), c["options_hash"], c["fork_id"])}
q5 = fcard(5); q4 = fcard(4); q2 = fcard(2)
q3 = fcard(3, "closed", ruling="cultlib-gaps:resolution:question.q3.n1", ruled_from=["n1"]); q1 = fcard(1, "closed", ruling="cultlib-gaps:resolution:question.q1.n1")
a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2)))
a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2)))
r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward"))
ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)]
def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit}
def judge_list(entries, ordinal, rq):
rows = sorted(entries, key=lambda e: -e["card"]["order"])
want = [e for e in rows if rq["status"] is None or (e["card"]["status"] == "open") == (rq["status"] == "open")]
if rq["cursor"] is not None: want = [e for e in want if e["card"]["order"] < int(rq["cursor"][1:])]
page = want[:rq["limit"]]
more = len(want) > len(page) and len(page) > 0
return {"schemaVersion": "gamecult.fork.list.v0", "mind": rq["mind"], "stamp": f"s{ordinal}",
"open_count": sum(e["card"]["status"] == "open" for e in rows), "recorded_count": sum(e["card"]["status"] == "closed" for e in rows),
"forks": page, "next_cursor": f"c{page[-1]['card']['order']}" if more else None}
def lstep(entries, ordinal, rq): r = judge_list(entries, ordinal, rq); return {"request": rq, "response": r, "responseHex": hx(r)}
LISTS = [
{"name": "newest_first_paged_open_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="open", limit=2)), lstep(ENTRIES, 412, lreq(status="open", cursor="c4", limit=2))]},
{"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]},
{"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]},
{"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]},
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
]
TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active}
AUTH = [
("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"),
("answer_with_desk_answer", "answer", TOK("bifrost-desk", "desk.answer"), "ok"), ("answer_with_both_scopes", "answer", TOK("bifrost-desk", "desk.read", "desk.answer"), "ok"),
("no_bearer_list", "list", None, "unauthenticated"), ("no_bearer_answer", "answer", None, "unauthenticated"),
("the_MAS_token_is_refused", "list", TOK("matrix-authentication-service", "openid", "profile"), "unauthenticated"),
("a_votes_token_cannot_list", "list", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
("a_votes_token_cannot_read", "read", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
("a_votes_token_cannot_answer", "answer", TOK("bifrost-votes", "votes.cast"), "unauthenticated"),
("desk_audience_with_a_votes_scope_only", "answer", TOK("bifrost-desk", "votes.cast"), "insufficient_scope"),
("desk_read_cannot_answer", "answer", TOK("bifrost-desk", "desk.read"), "insufficient_scope"),
("desk_answer_cannot_list", "list", TOK("bifrost-desk", "desk.answer"), "insufficient_scope"),
("a_non_active_member_cannot_list", "list", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
("a_non_active_member_cannot_read", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
("a_non_active_member_cannot_answer", "answer", TOK("bifrost-desk", "desk.answer", active=False), "forbidden"),
("scope_is_judged_before_membership", "answer", TOK("bifrost-desk", "desk.read", active=False), "insufficient_scope"),
("huginn_down_list", "list", TOK("bifrost-desk", "desk.read"), "unavailable", True),
("huginn_down_read", "read", TOK("bifrost-desk", "desk.read"), "unavailable", True),
("huginn_down_answer", "answer", TOK("bifrost-desk", "desk.answer"), "unavailable", True),
("auth_is_judged_before_huginn_is_asked", "list", None, "unauthenticated", True),
("membership_is_judged_before_huginn_is_asked", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden", True),
]
# ---------- transport: the {mind} segment (Heimdall's file 4.3a, M1 to M4) ----------
import urllib.parse
MINDS = {"gamecult-chat": "huginn-gamecult-chat.sock", "nibu": "huginn-nibu.sock"}
MIND_FORKS = {"gamecult-chat": ["gamecult-chat:question:q1", "gamecult-chat:question:q2", FID], "nibu": ["nibu:question:glow-order"]}
def auth_code(route, tok):
if tok is None or tok["audience"] != "bifrost-desk": return "unauthenticated"
if ("desk.answer" if route == "answer" else "desk.read") not in tok["scopes"]: return "insufficient_scope"
return "forbidden" if not tok["active"] else "ok"
def mind_route(route, seg, fork, tok):
code = auth_code(route, tok)
if code != "ok": return code, None, None
try: mind = urllib.parse.unquote(seg, errors="strict")
except UnicodeDecodeError: mind = None
if mind not in MINDS: return "fork_unknown", None, None # never joined into a path; no socket is opened
if route != "list" and fork not in MIND_FORKS[mind]: return "fork_unknown", MINDS[mind], None
return "ok", MINDS[mind], mind if route == "answer" else None
DESK_READ, DESK_ANSWER = TOK("bifrost-desk", "desk.read"), TOK("bifrost-desk", "desk.answer")
def mcase(name, route, seg, fork=None, tok=None):
tok = None if tok == "none" else tok or (DESK_ANSWER if route == "answer" else DESK_READ)
code, opened, stored = mind_route(route, seg, fork, tok)
return {"name": name, "route": route, "mind": seg, "fork_id": fork, "token": tok, "expect": code, "httpStatus": 200 if code == "ok" else HTTP[code], "opened": opened, "stored_in": stored}
BAD_SEGMENTS = [("dot_dot", "../x"), ("slash", "a/b"), ("encoded_slash", "a%2fb"), ("encoded_dot_dot", "%2e%2e%2fx"), ("nul", "eureka%00"), ("four_kb", "a" * 4096),
("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff"), ("dot", "."), ("dots", ".."), ("double_encoded", "%256eibu")]
MINDCASES = [
mcase("list_a_percent_encoded_configured_mind_is_decoded_once", "list", "%6eibu"), mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"),
mcase("read_a_fork_the_mind_holds", "read", "gamecult-chat", FID), mcase("answer_a_fork_the_mind_holds_is_stored_in_that_mind", "answer", "gamecult-chat", FID),
mcase("answer_on_the_other_mind", "answer", "nibu", "nibu:question:glow-order"),
] + [mcase(f"M1_{route}_{n}_is_unknown_and_opens_nothing", route, seg, FID if route != "list" else None) for route in ("list", "read", "answer") for n, seg in BAD_SEGMENTS] + [
mcase("M2_read_unknown_mind_known_fork", "read", "ghost", FID), mcase("M2_read_known_mind_unknown_fork", "read", "gamecult-chat", "gamecult-chat:question:ghost"),
mcase("M2_answer_unknown_mind_known_fork", "answer", "ghost", FID), mcase("M2_answer_known_mind_unknown_fork", "answer", "gamecult-chat", "gamecult-chat:question:ghost"),
mcase("M2_list_unknown_mind", "list", "ghost"),
mcase("M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "answer", "nibu", FID), mcase("M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", "answer", "gamecult-chat", "nibu:question:glow-order"),
mcase("M4_desk_read_lists_every_mind", "list", "nibu", tok=DESK_READ), mcase("M4_desk_read_reads_every_mind", "read", "nibu", "nibu:question:glow-order", tok=DESK_READ),
mcase("M4_desk_answer_cannot_list_any_mind", "list", "gamecult-chat", tok=DESK_ANSWER), mcase("M4_desk_answer_cannot_list_the_other_mind", "list", "nibu", tok=DESK_ANSWER),
mcase("M4_desk_read_cannot_answer_on_any_mind", "answer", "nibu", "nibu:question:glow-order", tok=DESK_READ),
mcase("auth_comes_before_the_mind_lookup_no_bearer", "list", "ghost", tok="none"), mcase("auth_comes_before_the_mind_lookup_scope", "answer", "ghost", FID, tok=DESK_READ),
mcase("auth_comes_before_the_mind_lookup_non_active", "read", "ghost", FID, tok=TOK("bifrost-desk", "desk.read", active=False)),
]
SAME_RESPONSE = [["M2_read_unknown_mind_known_fork", "M2_read_known_mind_unknown_fork"], ["M2_answer_unknown_mind_known_fork", "M2_answer_known_mind_unknown_fork"],
["M2_list_unknown_mind", "M2_read_known_mind_unknown_fork"], ["M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "M2_answer_known_mind_unknown_fork"]]
def bad(name, why, m, **kw): return dict({"name": name, "why": why, "message": m}, **kw)
DECIDERS_64 = [f"{i:08x}-0000-4000-8000-{i:012x}" for i in range(1, 65)] # the card's `deciders` holds at most 64 (Huginn's bound)
# body_sha256: Huginn's private "same body" digest (fork-card.md, "The mind's record"); no wire document carries it. Input per
# /opt/commons/handoff/desk-body-digest-order-20261110.md (Eureka): SHA-256 of compact serde_json UTF-8, keys in this order, null for missing,
# `retracts` the full stored key, request_id out. `illegal_retraction` and `retracted` are written only when the input has them (non-null).
DIGEST_KEYS = ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]
def digest_bytes(i): return json.dumps({k: i[k] for k in DIGEST_KEYS if k in ("fork", "options_hash", "answered_at", "choice", "note", "retracts") or i.get(k) is not None},
separators=(",", ":"), ensure_ascii=False).encode("utf-8")
DQ = {"fork": "eureka-state:question:Q1", "options_hash": "a" * 64, "answered_at": "2026-11-10T12:00:00Z"}
DT = "eureka-state:desk_answer:Q1.n1"
def dg(name, status, sha_expected, why, **kw):
i = dict(DQ, choice=None, note=None, retracts=None, illegal_retraction=None, retracted=None); i.update(kw)
b = digest_bytes(i); got = sha(b)
assert got == sha_expected, (name, got)
return {"name": name, "status": status, "why": why, "input": i, "canonical": b.decode("utf-8"), "sha256": got}
MAIN, FIX = "main_7ea11b4_must_not_move", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here"
BODY_DIGESTS = [
dg("plain_answer_choice_A", MAIN, "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a", "a plain answer: choice and note as sent, retracts null; no illegal or retracted key", choice="A"),
dg("legal_retraction", MAIN, "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3", "a legal retraction as Huginn main pins it: retracts is `Q1.1`, a string no door produces (the key grammar is .n<seq>); kept for the frozen-form test", retracts="eureka-state:desk_answer:Q1.1"),
dg("illegal_i_and_iii_alike_on_main", MAIN, "69074cc60829ef5edb12358746f7549ad08da97653d59359c945cea102f53565", "main writes `illegal_retraction: true` and no `retracted`, so (i) and (iii) collide: the defect the fix removes", choice="A", retracts=DT, illegal_retraction=True),
dg("legal_retraction_door_form", MAIN, "84576840247830b9b75754b460dc4ea52883ca1e410f9f011521e0f69b99eee1", "a legal retraction as the door produces it: the wire's n1 converted to the full stored key Q1.n1 before the digest; the golden that stands for the real form (digest unaffected by .n<seq> vs .1)", retracts=DT),
dg("illegal_ii_on_main", MAIN, "1a36c4aba0d1c30d0736f20243ebfdb84fe84a5eabd4f69709a096a32c3cc6e3", "(ii) no answer, no target, on main", illegal_retraction=True),
dg("illegal_i_retraction_with_an_answer_retracted_true", FIX, "935cab1a0ae34606b809d8ebca7cffa2889d20b3585d2622d32153a4d6898af1", "(i) answer present, retracted true, target Q1.n1", choice="A", retracts=DT, illegal_retraction=True, retracted=True),
dg("illegal_iii_plain_answer_with_a_target_retracted_false", FIX, "82b24cccb4ed2535a549f95f844fcd935074c3712b5b832f0b2cbd89c71fd9a6", "(iii) the same bytes as (i) with retracted false: another body, so request_id_conflict", choice="A", retracts=DT, illegal_retraction=True, retracted=False),
dg("illegal_i_retraction_with_an_answer_and_no_target_retracted_true", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", "79eee623fd6712db6537a4867cedf54ca49ee4885416c2c8e4acec59f811b894", "(i) with the target null (the order doc: retracts set or null); Eureka re-derived it with sha256sum (order doc addendum)", choice="A", illegal_retraction=True, retracted=True),
dg("illegal_ii_no_answer_no_target_retracted_true", FIX, "34ef86ed63b9318459965d4b85e794d2636ffc15736da866036b087c9c4a30eb", "(ii) no answer, no target, retracted true", illegal_retraction=True, retracted=True),
]
fork = {
"provenance": "Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Bytes spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. options_hash follows fork-card.md 'Options hash'; the vectors named heimdall_* are that file's, with its digests.",
"optionsHash": hashes,
"bodyDigests": BODY_DIGESTS,
"card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)},
{"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0),
"messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))},
{"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))},
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))},
{"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}],
"invalid": [bad(n, w, m) for n, w, m in [
("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)),
("depends_line_1001_code_points", "a depends line is at most 1000 characters", card(depends=["a" * 1001])),
("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])),
("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])),
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])),
("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)),
("unknown_key", "closed document", dict(card(), note="x")),
("option_with_a_label", "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", card(options=[dict(OPT[0], label="Yes"), OPT[1]])),
("status_unknown", "status is open or closed", card(status="provisional")),
("options_hash_not_hex", "options_hash is 64 lowercase hex", dict(card(), options_hash="XYZ")),
("duplicate_option_ids", "rule: option ids are unique (not expressible in the schema)", card(options=[OPT[0], dict(OPT[1], id=OPT[0]["id"])])),
("recommendation_not_an_option", "rule: recommendation is an option id (not expressible in the schema)", card(recommendation="maybe")),
("leans_not_an_option", "rule: leans is nil or an option id (not expressible in the schema)", card(leans="maybe")),
("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP])),
("ruled_from_on_an_open_card", "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (not expressible in the schema)", card(ruled_from=["n1"])),
("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")),
("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])),
("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)),
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"])),
]]},
"request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
[("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")),
("note_4000_ascii_bytes", body(None, "x" * 4000)), ("note_astral_4000_bytes", body(None, "\U0001F600" * 1000)),
("note_combining_4000_bytes", body(None, "e\u0301" * 1333 + "x"))]],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("answer_with_neither", "an answer object holds a choice, a note, or both", body(answer={"choice": None, "note": None})),
("blank_note", "a blank note is no answer", body(None, " \u00a0 ")),
("request_id_missing", "map-mode writes every key; a retry needs the id", {k: v for k, v in x1.items() if k != "request_id"}),
("request_id_uppercase", "request_id is lowercase", dict(x1, request_id="0000000A-0000-4000-8000-00000000000B")),
("request_id_not_a_uuid", "request_id is a lowercase D-format UUID", dict(x1, request_id="retry-1")),
("note_over_4000", "notes are at most 4000 UTF-8 bytes", body(None, "x" * 4001)),
("note_multibyte_4001_bytes", "a note is counted in UTF-8 bytes: 1999 e-acute is 3998 bytes, plus three ASCII is 4001 (rule: not expressible in the schema, 2002 characters)", body(None, "\u00e9" * 1999 + "abc")),
("note_astral_4004_bytes", "1001 astral characters are 4004 bytes though only 1001 code points (rule: not expressible in the schema)", body(None, "\U0001F600" * 1001)),
("note_combining_4002_bytes", "e plus a combining acute is 3 bytes: 1334 of them are 4002 (rule: not expressible in the schema)", body(None, "e\u0301" * 1334)),
("options_hash_uppercase", "hash is lowercase hex", body("no", None, h=H.upper())),
("answerer_key", "answerer is server-set; the door answers body_forbidden_field", dict(x1, answerer=OP)),
("authority_key", "authority is server-set; the door answers body_forbidden_field", dict(x1, authority="Operator")),
("answer_object_with_a_key", "the answer object is closed", body(answer={"choice": "no", "note": None, "weight": 1})),
("answered_at_without_zone", "instants are UTC with a Z", body("no", None, at="2026-11-12T19:02:00")),
("retracted_not_a_boolean", "retracted is a boolean", dict(x1, retracted="no")),
("fork_id_empty", "fork_id names a fork", body("no", None, fork="")),
]]},
"deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
[("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))),
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")),
("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)),
("refused_with_an_unknown_code", "the code set is closed", dict(REF, code="because")),
("counted_naming_another_answer", "Counted names none", dict(CNT, of="n0")),
("revision_naming_none", "a Revision names the counted answer", dict(REV, of=None)),
("revision_that_also_retracts", "a variant carries only its own link (a retraction with no target, or a target with no retraction, cannot exist)", dict(REV, retracts="n1")),
("retraction_naming_no_target", "a Retraction names the answer it retracts", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), retracts=None)),
("refused_naming_another_answer_as_of", "a Refused answer names no counted answer (`of` is a Revision's)", dict(REF, of="n1")),
("refused_with_a_target_that_is_not_an_answer_id", "a Refused answer's retracts is nil or an answer id (n<seq>), never the stored key", dict(REF, retracts="cultlib-gaps:desk_answer:slot-mark-consent.n1")),
("retraction_carrying_a_choice", "a Retraction carries no choice or note", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), choice="no")),
("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")),
("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")),
]]},
"results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS],
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL],
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
{"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}],
"rulingCites": cites,
"transport": {
"listRequest": {"valid": [{"name": "list_open_page", "message": lreq(status="open", limit=2), "messagePackHex": hx(lreq(status="open", limit=2))},
{"name": "list_next_page", "message": lreq(status="open", cursor="c4", limit=2), "messagePackHex": hx(lreq(status="open", cursor="c4", limit=2))},
{"name": "list_count_only", "message": lreq(limit=0), "messagePackHex": hx(lreq(limit=0))}],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("limit_51", "a page is at most 50 forks", lreq(limit=51)), ("limit_negative", "limit is 0 to 50", lreq(limit=-1)),
("status_all", "status is open, recorded or nil", lreq(status="all")), ("cursor_malformed", "a cursor is c<N>", lreq(cursor="next")),
("cursor_zero", "a cursor names a positive order", lreq(cursor="c0")), ("mind_empty", "a list names a Mind", lreq(mind="")),
("unknown_key", "closed document", dict(lreq(), sort="oldest"))]]},
"readRequest": {"valid": [{"name": "read_one", "message": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID}, "messagePackHex": hx({"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID})}],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}),
("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}),
("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]},
"entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}),
("record_of_another_fork", "rule: the record names the card's fork (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], fork_id="gamecult-chat:question:q9"))),
("record_under_another_hash", "rule: the record is under the card's options_hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], options_hash=STALE_H))),
("counted_answer_not_derived", "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], counted_answer_id=None))),
("card_hash_wrong", "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", dict(ENTRIES[1], card=dict(ENTRIES[1]["card"], options_hash=STALE_H), record=dict(ENTRIES[1]["record"], options_hash=STALE_H)))]]},
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]],
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
"mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE},
"authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH],
},
"scenarios": [{"name": n, "card": c, "optionsHash": c["options_hash"], "steps": steps, "record": r, "recordHex": hx(r)} for n, c, steps, r in scen],
}
(OUT / "fork-card-vectors.json").write_text(json.dumps(fork, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print("ok", len(vote["valid"]), len(vote["invalid"]), len(fork["scenarios"]))

View file

@ -1,119 +0,0 @@
#!/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

@ -390,32 +390,15 @@ namespace GameCult.Networking
} }
} }
// A write that names nothing is refused as a frame that does not read, exactly as the Rust server's decoder refuses it
// (write-receipt.md, "Required fields"), never handed to the store: a blank schemaId, recordKey or storedAt, an empty
// payload, and a source id or tag that is present but blank.
private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) =>
IsBlank(schemaId) || IsBlank(recordKey);
private static bool IsUnreadableDocument(CultNetRawDocumentRecord document) =>
IsUnreadableWriteTarget(document.SchemaId, document.RecordKey)
|| IsBlank(document.StoredAt)
|| document.Payload == null || document.Payload.Length == 0
|| IsPresentButBlank(document.SourceRuntimeId)
|| IsPresentButBlank(document.SourceAgentId)
|| IsPresentButBlank(document.SourceRole)
|| document.Tags != null && document.Tags.Any(IsBlank);
private static bool IsPresentButBlank(string? value) => value != null && IsBlank(value);
// Blank is the Unicode White_Space property, listed here and in write-receipt.md, not char.IsWhiteSpace: the platform's
// own test follows its Unicode tables (and has put U+180E in the set), the spec's does not move. A null is not text.
private static bool IsBlank(string? value) => value == null || value.All(IsWhiteSpaceCodePoint);
private static bool IsWhiteSpaceCodePoint(char c) =>
c is (>= '\u0009' and <= '\u000D') or '\u0020' or '\u0085' or '\u00A0' or '\u1680'
or (>= '\u2000' and <= '\u200A') or '\u2028' or '\u2029' or '\u202F' or '\u205F' or '\u3000';
// A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other.
// An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust
// server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store.
private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) =>
string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey);
private static CultNetErrorMessage UnreadableWriteTarget() =>
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read");
private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error)
{ {
if (write != null) write.Refused(error); if (write != null) write.Refused(error);
@ -456,9 +439,9 @@ namespace GameCult.Networking
return; return;
} }
if (IsUnreadableDocument(message.Document!)) if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey))
{ {
Refuse(peer, write, CultNetErrorMessage.ForUnreadableMessage()); Refuse(peer, write, UnreadableWriteTarget());
return; return;
} }
@ -539,7 +522,7 @@ namespace GameCult.Networking
if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey)) if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey))
{ {
Refuse(peer, write, CultNetErrorMessage.ForUnreadableMessage()); Refuse(peer, write, UnreadableWriteTarget());
return; return;
} }

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Buffers;
using MessagePack; using MessagePack;
namespace GameCult.Networking namespace GameCult.Networking
@ -30,7 +29,6 @@ namespace GameCult.Networking
public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage
{ {
if (payload == null) throw new ArgumentNullException(nameof(payload)); if (payload == null) throw new ArgumentNullException(nameof(payload));
if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload);
return MessagePackSerializer.Deserialize<T>(payload, Options); return MessagePackSerializer.Deserialize<T>(payload, Options);
} }
@ -100,67 +98,4 @@ namespace GameCult.Networking
[Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; [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

@ -587,13 +587,6 @@ namespace GameCult.Networking
Details = new CultNetErrorDetails { Field = field } Details = new CultNetErrorDetails { Field = field }
}; };
/// <summary>
/// The refusal of a frame that does not read, or whose write names nothing (write-receipt.md, Fixed texts):
/// <c>message_invalid</c>, details <c>{ field: "message" }</c>, and the one fixed text. A decoder's own text never reaches the wire.
/// </summary>
public static CultNetErrorMessage ForUnreadableMessage() =>
ForMessageInvalid("message", "the message could not be read");
/// <summary> /// <summary>
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details /// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details. /// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.

View file

@ -3571,7 +3571,7 @@ namespace GameCult.Networking
try try
{ {
SendSchema(CultNetErrorV1Message.From( SendSchema(CultNetErrorV1Message.From(
CultNetErrorMessage.ForUnreadableMessage(), CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"),
CultNetReadableMessageId.From(payload))); CultNetReadableMessageId.From(payload)));
} }
catch (Exception) catch (Exception)

View file

@ -1,144 +0,0 @@
// 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) 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 | receipt-serve | receipt-relay"); 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");
} }
var mode = args[0]; var mode = args[0];
@ -53,12 +53,6 @@ static async Task<int> ProgramMainAsync(string[] args)
case "rudp-dial-message-once": case "rudp-dial-message-once":
RudpDialMessageOnce(options); RudpDialMessageOnce(options);
return 0; 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": case "quic-realtime-serve":
await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options)); await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options));
return 0; return 0;

View file

@ -1,120 +0,0 @@
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

@ -1,105 +0,0 @@
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());
}
}
}
}

View file

@ -1,9 +1,7 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -1033,163 +1031,6 @@ namespace GameCult.Networking.Tests
Assert.That(rig.TextOf(KeyA), Is.Null); Assert.That(rig.TextOf(KeyA), Is.Null);
} }
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> BlankFieldCases() =>
Vectors().GetProperty("blankFields").GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("name").GetString()!);
// The one fixed text of a frame that does not read or a write that names nothing, read from the vector that carries it.
private static string UnreadableVectorText() =>
Vectors().GetProperty("wire").EnumerateArray()
.Single(v => v.GetProperty("name").GetString() == "error_v1_empty_schema_id_is_unreadable_and_names_the_write")
.GetProperty("message").GetProperty("error").GetString()!;
// write-receipt-vectors.json `blankFields`: every case, on a receipt-capable and on a legacy connection. The rig's put is
// the valid document; the case's `set` replaces the named fields (the vectors' payload bytes stand for any non-empty payload).
[Test]
public async Task APutThatNamesNothingIsRefusedAsUnreadableAndANamedOneIsAdmitted(
[ValueSource(nameof(BlankFieldCases))] string name, [Values] bool capable)
{
var vectors = Vectors().GetProperty("blankFields").GetProperty("cases").EnumerateArray()
.Single(c => c.GetProperty("name").GetString() == name);
var rig = Build();
var wire = new Wire();
if (capable) await rig.Hello(wire, BothFrames);
wire.Sent.Clear();
var message = rig.PutOf("m1", KeyA, "one");
var document = message.Document!;
foreach (var field in vectors.GetProperty("set").EnumerateObject())
{
var value = field.Value;
var text = value.ValueKind == JsonValueKind.String ? value.GetString() : null;
switch (field.Name)
{
case "schemaId": document.SchemaId = text!; break;
case "recordKey": document.RecordKey = text!; break;
case "storedAt": document.StoredAt = text!; break;
case "payloadHex": document.Payload = Convert.FromHexString(text!); break;
case "sourceRuntimeId": document.SourceRuntimeId = text; break;
case "sourceAgentId": document.SourceAgentId = text; break;
case "sourceRole": document.SourceRole = text; break;
case "tags": document.Tags = value.ValueKind == JsonValueKind.Null ? null : value.EnumerateArray().Select(t => t.GetString()!).ToArray(); break;
default: Assert.Fail($"unknown field {field.Name}"); break;
}
}
await rig.PutHandler(message, wire.Peer);
if (vectors.GetProperty("outcome").GetString() == "admitted")
{
if (capable) AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey);
else Assert.That(wire.Sent, Is.Empty, "a legacy connection is sent no receipt");
if (!vectors.GetProperty("set").EnumerateObject().Any(f => f.Name is "schemaId" or "recordKey"))
Assert.That(rig.TextOf(KeyA), Is.EqualTo("one"));
return;
}
var refused = wire.Sent.Single();
var error = capable ? AssertRefused(refused, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)refused;
Assert.That(error.Code, Is.EqualTo("message_invalid"));
Assert.That(error.Details!.Field, Is.EqualTo("message"));
Assert.That(error.Error, Is.EqualTo(UnreadableVectorText()));
Assert.That(rig.TextOf(KeyA), Is.Null, "a write that names nothing is never applied");
}
public static IEnumerable<string> WhiteSpaceCodePoints() =>
Vectors().GetProperty("blankFields").GetProperty("whiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!);
public static IEnumerable<string> NonWhiteSpaceCodePoints() =>
Vectors().GetProperty("blankFields").GetProperty("notWhiteSpaceCodePoints").EnumerateArray().Select(c => c.GetString()!);
private static readonly string[] BlankRuleFields =
{ "schemaId", "recordKey", "storedAt", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags" };
// Blank is the Unicode White_Space property in every runtime (write-receipt.md, Required fields), not whatever the
// platform's char.IsWhiteSpace says: each listed code point, alone or repeated, in each covered field is refused as
// unreadable and never applied; each listed non-White_Space code point (U+200B, U+FEFF, U+180E, ...) is a name.
private async Task AssertBlankRule(string hex, bool blank)
{
foreach (var field in BlankRuleFields)
foreach (var times in new[] { 1, 3 })
{
var text = string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(Convert.ToInt32(hex, 16)), times));
var rig = Build();
var wire = new Wire();
await rig.Hello(wire, BothFrames);
wire.Sent.Clear();
var message = rig.PutOf("m1", KeyA, "one");
var document = message.Document!;
switch (field)
{
case "schemaId": document.SchemaId = text; break;
case "recordKey": document.RecordKey = text; break;
case "storedAt": document.StoredAt = text; break;
case "sourceRuntimeId": document.SourceRuntimeId = text; break;
case "sourceAgentId": document.SourceAgentId = text; break;
case "sourceRole": document.SourceRole = text; break;
default: document.Tags = new[] { "a", text }; break;
}
var what = $"U+{hex} in {field} x{times}";
await rig.PutHandler(message, wire.Peer);
if (blank)
{
var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid").ToV0();
Assert.That(error.Details!.Field, Is.EqualTo("message"), what);
Assert.That(error.Error, Is.EqualTo(UnreadableVectorText()), what);
Assert.That(rig.TextOf(KeyA), Is.Null, $"{what}: never applied");
}
else
{
AssertAdmitted(wire.Sent.Single(), "m1", "put", document.RecordKey);
}
}
}
[Test]
public async Task EveryUnicodeWhiteSpaceCodePointIsBlank([ValueSource(nameof(WhiteSpaceCodePoints))] string hex) =>
await AssertBlankRule(hex, blank: true);
[Test]
public async Task CodePointsOutsideWhiteSpaceNameSomething([ValueSource(nameof(NonWhiteSpaceCodePoints))] string hex) =>
await AssertBlankRule(hex, blank: false);
// A nil payload names no document either; it is refused like an empty one, never handed to the store.
[Test]
public async Task APutWithANilPayloadIsRefusedAsUnreadable([Values] bool capable)
{
var rig = Build();
var wire = new Wire();
if (capable) await rig.Hello(wire, BothFrames);
wire.Sent.Clear();
var message = rig.PutOf("m1", KeyA, "one");
message.Document!.Payload = null!;
await rig.PutHandler(message, wire.Peer);
var refused = wire.Sent.Single();
var error = capable ? AssertRefused(refused, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)refused;
Assert.That(error.Error, Is.EqualTo(UnreadableVectorText()));
Assert.That(rig.TextOf(KeyA), Is.Null);
}
// The fixed text is also what an undecodable frame is answered with, from the transport, not a decoder's wording.
[Test]
public async Task AnUnreadableSchemaFrameIsAnsweredWithTheFixedText()
{
var (_, wire) = await Capable();
wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 });
Assert.That(AssertRefused(wire.Sent.Single(), null, "message_invalid").Error, Is.EqualTo(UnreadableVectorText()));
}
[TestCase("", "k")] [TestCase("", "k")]
[TestCase(" ", "k")] [TestCase(" ", "k")]
[TestCase("note.v0", "")] [TestCase("note.v0", "")]

View file

@ -1,683 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with MessagePack C#, must reproduce the bytes, judges
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
public sealed class GovernanceVectorTests
{
private const string VoteSchema = "governance/vote-api.schema.json";
private const string ForkSchema = "governance/fork-card.schema.json";
private static string RepoRoot()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
}
private static JsonNode Load(string name) =>
JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", "governance", name)))!;
private static readonly JsonNode Vote = Load("vote-api-vectors.json");
private static readonly JsonNode Fork = Load("fork-card-vectors.json");
private static MiniJsonSchemaValidator Validator() => new(Path.Combine(RepoRoot(), "contracts", "cultnet"));
private static List<string> Errors(string file, string def, JsonNode? value)
{
var element = JsonSerializer.SerializeToElement(value);
return Validator().Validate(element, $"{file}#/$defs/{def}");
}
// MessagePack C# writes the message in the order the keys appear: shortest ints, shortest strings.
private static void Write(ref MessagePackWriter w, JsonNode? n)
{
switch (n)
{
case null: w.WriteNil(); break;
case JsonObject o:
w.WriteMapHeader(o.Count);
foreach (var kv in o) { w.Write(kv.Key); Write(ref w, kv.Value); }
break;
case JsonArray a:
w.WriteArrayHeader(a.Count);
foreach (var item in a) Write(ref w, item);
break;
case JsonValue v when v.TryGetValue<bool>(out var b): w.Write(b); break;
case JsonValue v when v.TryGetValue<int>(out var i): w.Write(i); break;
case JsonValue v when v.TryGetValue<long>(out var l): w.Write(l); break;
case JsonValue v: w.Write(v.GetValue<string>()); break;
}
}
private static string HexOf(JsonNode? message)
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
Write(ref writer, message);
writer.Flush();
return Convert.ToHexString(buffer.WrittenSpan);
}
private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private static string S(JsonNode? n) => n!.GetValue<string>();
private static JsonNode? Clone(JsonNode? n) => n is null ? null : JsonNode.Parse(n.ToJsonString());
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
private static IEnumerable<string> VoteRuleErrors(string def, JsonNode? message)
{
if (def == "cast" && message?["ballot"] is JsonNode ballot && S(ballot["reason"]).Length > 280) yield return "reason is over 280 UTF-16 code units";
}
[Test]
public void VoteApi_ValidDocuments_ValidateEncodeToTheVectorBytesAndDecodeBack()
{
foreach (var v in Vote["valid"]!.AsArray())
{
var name = S(v!["name"]);
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]), Is.Empty, name);
Assert.That(VoteRuleErrors(S(v["def"]), v["message"]), Is.Empty, name);
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, name);
}
}
[Test]
public void VoteApi_EveryNegative_IsRefusedBySchemaAndKeepsItsBytes()
{
foreach (var v in Vote["invalid"]!.AsArray())
{
var name = S(v!["name"]);
Assert.That(Errors(VoteSchema, S(v["def"]), v["message"]).Count > 0 || VoteRuleErrors(S(v["def"]), v["message"]).Any(), Is.True, $"{name}: {S(v["why"])}");
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), name);
}
}
private static byte[] CommitmentBytes(JsonNode b, string voter) => Encoding.UTF8.GetBytes(
$"gamecult.sealed_ballot.v0\n{S(b["motionId"])}\n{voter}\n{S(b["persona"])}\n{b["round"]!.GetValue<long>()}\n{S(b["candidate"])}\n{S(b["reason"])}\n{S(b["salt"])}");
[Test]
public void VoteApi_Commitment_IsSha256OfTheCanonicalBytes_AndRevealMatchesExactlyWhenItHashesToIt()
{
foreach (var c in Vote["commitment"]!.AsArray())
{
var bytes = CommitmentBytes(c!["ballot"]!, S(c["voterUserAccountId"]));
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(c["canonicalHex"])), S(c["name"]));
Assert.That(Sha(bytes), Is.EqualTo(S(c["sha256"])), S(c["name"]));
}
foreach (var p in Vote["pairs"]!.AsArray())
{
var digest = Sha(CommitmentBytes(p!["reveal"]!["ballot"]!, S(p["voterUserAccountId"])));
Assert.That(digest == S(p["commit"]!["commitment"]), Is.EqualTo(p["matches"]!.GetValue<bool>()), S(p["name"]));
Assert.That(Errors(VoteSchema, "cast", p["commit"]), Is.Empty);
Assert.That(Errors(VoteSchema, "cast", p["reveal"]), Is.Empty);
}
}
[Test]
public void VoteApi_EachRefusalCode_HasAVectorAStatusAndTheVectorBytes()
{
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", VoteSchema)))!;
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
var vectors = Vote["refusals"]!.AsArray();
Assert.That(vectors.Select(r => S(r!["message"]!["code"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(codes));
foreach (var r in vectors)
{
Assert.That(Errors(VoteSchema, "refusal", r!["message"]), Is.Empty, S(r["name"]));
Assert.That(HexOf(r["message"]), Is.EqualTo(S(r["messagePackHex"])), S(r["name"]));
Assert.That(r["httpStatus"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(400));
}
Assert.That(vectors.Single(r => S(r!["name"]) == "rate_limited")!["message"]!["retryAfterSeconds"]!.GetValue<int>(), Is.GreaterThanOrEqualTo(1));
}
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
private static string Jstr(string s)
{
var sb = new StringBuilder("\"");
foreach (var rune in s.EnumerateRunes())
{
var o = rune.Value;
sb.Append(o switch
{
'"' => "\\\"", '\\' => "\\\\", 8 => "\\b", 9 => "\\t", 10 => "\\n", 12 => "\\f", 13 => "\\r",
< 0x20 => "\\u" + o.ToString("x4"),
_ => rune.ToString()
});
}
return sb.Append('"').ToString();
}
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
private static string N(string s) => s.Normalize(NormalizationForm.FormC);
private static byte[] Canonical(JsonNode c)
{
var options = string.Join(",", c["options"]!.AsArray().Select(o => $"{{\"id\":{Jstr(N(S(o!["id"])))},\"text\":{Jstr(N(S(o["text"])))}}}"));
var depends = string.Join(",", c["depends"]!.AsArray().Select(d => Jstr(N(S(d)))));
return Encoding.UTF8.GetBytes($"{{\"context\":{Jstr(N(S(c["context"])))},\"depends\":[{depends}],\"options\":[{options}],\"recommendation\":{Jstr(N(S(c["recommendation"])))},\"title\":{Jstr(N(S(c["title"])))}}}");
}
private static string HashOf(JsonNode c) => Sha(Canonical(c));
private static IEnumerable<string> CardRuleErrors(JsonNode c)
{
var ids = c["options"]!.AsArray().Select(o => S(o!["id"])).ToList();
if (ids.Distinct().Count() != ids.Count) yield return "duplicate option ids";
if (!ids.Contains(S(c["recommendation"]))) yield return "recommendation is not an option";
if (c["leans"] is not null && !ids.Contains(S(c["leans"]))) yield return "leans is not an option";
var deciders = c["deciders"]!.AsArray().Select(S).ToList();
if (deciders.Distinct().Count() != deciders.Count) yield return "duplicate deciders";
if (c["depends"]!.AsArray().Any(d => Encoding.UTF8.GetByteCount(S(d)) > 1000)) yield return "a depends line is over 1000 UTF-8 bytes";
if (S(c["status"]) == "open" && c["ruling"] is not null) yield return "an open card names a ruling";
if (c["ruling"] is null && c["ruled_from"]!.AsArray().Count > 0) yield return "ruled_from without a ruling";
if (S(c["options_hash"]) != HashOf(c)) yield return "options_hash is not the hash of the shown members";
}
// body_sha256 is Huginn's private "same body" digest, not a wire field: compact JSON, fixed key order, null for missing, the two illegal-form keys only when present.
[Test]
public void ForkCard_AnAnswerResultWithoutCountedAnswerId_IsRefusedBySchema()
{
var result = Clone(Fork["scenarios"]!.AsArray().SelectMany(sc => sc!["steps"]!.AsArray()).First(st => st!["response"]?["answer"] is not null)!["response"])!.AsObject();
Assert.That(Errors(ForkSchema, "answerResult", result), Is.Empty);
result.Remove("counted_answer_id");
Assert.That(Errors(ForkSchema, "answerResult", result), Is.Not.Empty);
}
[Test]
public void ForkCard_BodyDigest_IsSha256OfTheCompactJsonInTheStatedKeyOrder_AndTheFixedFormsSeparateRetractedTrueFromFalse()
{
var options = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
var digests = Fork["bodyDigests"]!.AsArray().ToDictionary(d => S(d!["name"]), d => d!);
foreach (var d in digests.Values)
{
var i = d["input"]!;
var o = new JsonObject();
foreach (var key in new[] { "fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted" })
if (key is "illegal_retraction" or "retracted" ? i[key] is not null : true) o[key] = Clone(i[key]);
var json = o.ToJsonString(options);
Assert.That(json, Is.EqualTo(S(d["canonical"])), S(d["name"]));
Assert.That(Sha(Encoding.UTF8.GetBytes(json)), Is.EqualTo(S(d["sha256"])), S(d["name"]));
}
Assert.That(S(digests["plain_answer_choice_A"]["sha256"]), Does.StartWith("8a4d5b2e"));
Assert.That(S(digests["legal_retraction"]["sha256"]), Does.StartWith("9f807538"));
Assert.That(S(digests["legal_retraction_door_form"]["sha256"]), Does.StartWith("84576840"));
Assert.That(digests["legal_retraction_door_form"]["input"]!["retracts"]!.ToString(), Does.EndWith(".n1"), "the door's form: the wire n<seq> converted to the full key");
Assert.That(S(digests["illegal_i_retraction_with_an_answer_retracted_true"]["sha256"]), Does.StartWith("935cab1a"));
Assert.That(S(digests["illegal_iii_plain_answer_with_a_target_retracted_false"]["sha256"]), Does.StartWith("82b24ccc"));
Assert.That(S(digests["illegal_ii_no_answer_no_target_retracted_true"]["sha256"]), Does.StartWith("34ef86ed"));
Assert.That(digests.Values.Select(d => S(d["sha256"])).Distinct().Count(), Is.EqualTo(digests.Count), "no two vectors share a digest");
}
[Test]
public void ForkCard_OptionsHash_IsSha256OfTheCanonicalJson_ComputedHere_AndEveryShownMemberMovesIt()
{
foreach (var h in Fork["optionsHash"]!.AsArray())
{
var bytes = Canonical(h!);
Assert.That(Encoding.UTF8.GetString(bytes), Is.EqualTo(S(h["canonical"])), S(h["name"]));
Assert.That(Convert.ToHexString(bytes), Is.EqualTo(S(h["canonicalHex"])), S(h["name"]));
Assert.That(Sha(bytes), Is.EqualTo(S(h["sha256"])), S(h["name"]));
}
var byName = Fork["optionsHash"]!.AsArray().ToDictionary(h => S(h!["name"]), h => S(h!["sha256"]));
var baseline = byName["heimdall_A_five_members"];
Assert.That(byName["hidden_members_do_not_move_it"], Is.EqualTo(baseline));
Assert.That(byName["heimdall_E_decomposed_equals_D"], Is.EqualTo(byName["heimdall_D_nfc_escapes_astral"]), "an NFD and an NFC spelling hash alike");
foreach (var moved in new[] { "heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied",
"option_text_changed", "title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed" })
Assert.That(byName[moved], Is.Not.EqualTo(baseline), moved);
}
[Test]
public void ForkCard_MutatingAnyShownMember_AnOptionIdIncluded_ChangesTheHash()
{
var card = Fork["card"]!["valid"]![0]!["message"]!;
var baseline = HashOf(card);
var mutations = new Action<JsonNode>[]
{
c => c["title"] = S(c["title"]) + "x", c => c["context"] = S(c["context"]) + "x",
c => c["options"]![0]!["id"] = S(c["options"]![0]!["id"]) + "x", c => c["options"]![0]!["text"] = S(c["options"]![0]!["text"]) + "x",
c => { var o = c["options"]!.AsArray(); var first = o[0]!; o.RemoveAt(0); o.Add(first); },
c => c["recommendation"] = S(c["options"]![0]!["id"]) == S(c["recommendation"]) ? S(c["options"]![1]!["id"]) : S(c["options"]![0]!["id"]),
c => c["depends"]!.AsArray().Add("a:b:c"), c => c["depends"]!.AsArray().Clear(),
};
for (var i = 0; i < mutations.Length; i++)
{
var copy = Clone(card);
mutations[i](copy);
Assert.That(HashOf(copy), Is.Not.EqualTo(baseline), $"mutation {i}");
}
var hidden = Clone(card);
hidden["fork_id"] = "other:question:id"; hidden["leans"] = "yes"; hidden["status"] = "closed"; hidden["ruled_from"] = new JsonArray("n1");
Assert.That(HashOf(hidden), Is.EqualTo(baseline), "fork_id and the unshown members stay outside the hash");
}
[Test]
public void ForkCard_DocumentsValidateAndEncodeToTheVectorBytes()
{
foreach (var v in Fork["card"]!["valid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "card", v!["message"]), Is.Empty, S(v["name"]));
Assert.That(CardRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
foreach (var v in Fork[section]!["valid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
Assert.That(NoteRuleErrors(def, v["message"]), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
var decoded = JsonNode.Parse(MessagePackSerializer.ConvertToJson(Convert.FromHexString(S(v["messagePackHex"]))))!;
Assert.That(JsonNode.DeepEquals(decoded, v["message"]), Is.True, S(v["name"]));
}
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
IEnumerable<string> codesOfRefusal() => schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S);
IEnumerable<string> Codes(string def) => schema["$defs"]![def]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).OrderBy(x => x, StringComparer.Ordinal);
Assert.That(Fork["results"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("deskAnswerRefused")));
foreach (var v in Fork["results"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "answerResult", v!["message"]), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
Assert.That(v["message"]!["card"] is not null, Is.EqualTo(S(v["name"]) == "stale_fork"), "only a stale refusal shows the new fork");
}
Assert.That(Fork["doorRefusals"]!.AsArray().Select(r => S(r!["name"])).OrderBy(x => x, StringComparer.Ordinal), Is.EqualTo(Codes("refusal")));
foreach (var v in Fork["unavailable"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "unavailable", v!["message"]), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
Assert.That(v["httpStatus"]!.GetValue<int>(), Is.EqualTo(503));
Assert.That(codesOfRefusal(), Does.Not.Contain("unavailable"), "unavailable is kept apart from the refusal set");
}
foreach (var v in Fork["doorRefusals"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
}
[Test]
public void ForkCard_EveryNegative_IsRefusedBySchemaOrAStatedRule()
{
foreach (var v in Fork["card"]!["invalid"]!.AsArray())
Assert.That(Errors(ForkSchema, "card", v!["message"]).Count > 0 || CardRuleErrors(v["message"]!).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
foreach (var (section, def) in new[] { ("request", "answerRequest"), ("deskAnswer", "deskAnswer") })
foreach (var v in Fork[section]!["invalid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || NoteRuleErrors(def, v["message"]).Any(), Is.True, $"{S(v["name"])}: {S(v["why"])}");
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
}
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
private static IEnumerable<string> NoteRuleErrors(string def, JsonNode? message)
{
var note = def switch { "answerRequest" => message?["answer"]?["note"], "deskAnswer" => message?["note"], _ => null };
if (note is not null && Encoding.UTF8.GetByteCount(S(note)) > 4000) yield return "note is over 4000 UTF-8 bytes";
}
private static readonly Dictionary<string, string> Tails = new()
{
["unauthenticated"] = "a verified session is required",
["body_forbidden_field"] = "answerer and authority are set by the receiver",
["body_unknown_field"] = "the body is a closed shape",
["body_invalid"] = "the body does not satisfy gamecult.fork.answer_request.v0",
["fork_unknown"] = "no such fork",
["forbidden"] = "only Active members may use the desk",
["request_id_conflict"] = "this request_id was used for a different body",
};
private static readonly string[] RequestKeys = { "schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id" };
private static bool HasKey(JsonNode? n, params string[] names) => n switch
{
JsonObject o => o.Any(kv => names.Contains(kv.Key) || HasKey(kv.Value, names)),
JsonArray a => a.Any(x => HasKey(x, names)),
_ => false
};
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
private static (List<JsonNode?> Responses, JsonNode Record) Judge(JsonNode startCard, JsonArray steps)
{
var card = startCard;
var answers = new JsonArray();
var responses = new List<JsonNode?>();
var seen = new Dictionary<string, (JsonNode Body, JsonNode Response)>(); // (answerer, request_id) -> the first stored outcome
string? Counted()
{
var hash = HashOf(card);
foreach (var a in answers)
{
if (S(a!["outcome"]) is not ("Counted" or "Revision") || S(a["options_hash"]) != hash) continue;
if (answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))) continue;
return S(a["answer_id"]);
}
return null;
}
foreach (var step in steps)
{
var kind = S(step!["kind"]);
if (kind == "matrix_event") { responses.Add(null); continue; } // rule 9: Matrix never changes a decision
if (kind == "card") { card = step["card"]!; responses.Add(null); continue; }
var s = step["server"]!;
var b = step["body"]!.AsObject();
JsonNode Door(string code) => new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" };
JsonNode Store(string outcome, string? link, string? code)
{
var a = b["answer"] as JsonObject;
var doc = new JsonObject
{
["schemaVersion"] = "gamecult.desk_answer.v0", ["answer_id"] = S(s["answer_id"]), ["fork_id"] = Clone(b["fork_id"]), ["outcome"] = outcome,
["of"] = outcome == "Revision" ? link : null, ["retracts"] = outcome == "Retraction" ? link : outcome == "Refused" ? Clone(b["retracts"]) : null, ["code"] = outcome == "Refused" ? code : null,
["options_hash"] = Clone(b["options_hash"]), ["choice"] = Clone(a?["choice"]), ["note"] = Clone(a?["note"]), ["answered_at"] = Clone(b["answered_at"]),
["received_at"] = Clone(s["received_at"]),
["claim_in_future"] = (DateTimeOffset.Parse(S(b["answered_at"])) - DateTimeOffset.Parse(S(s["received_at"]))).TotalSeconds > 300,
["answerer"] = Clone(s["answerer"]), ["authority"] = Clone(s["authority"]), ["request_id"] = Clone(b["request_id"])
};
answers.Add(Clone(doc));
var stored = new JsonObject { ["schemaVersion"] = "gamecult.fork.answer_result.v0", ["answer"] = doc, ["card"] = code == "stale_fork" ? Clone(card) : null, ["counted_answer_id"] = Counted() };
seen[S(s["answerer"]) + "|" + S(b["request_id"])] = (Clone(b)!, Clone(stored)!);
return stored;
}
JsonNode Refuse(string code) => Store("Refused", null, code);
var deciders = card["deciders"]!.AsArray().Select(S).ToList();
var retracted = b["retracted"] is JsonValue rv && rv.TryGetValue<bool>(out var rb) && rb;
var down = s["door_down"] is not null; // the second door failed to answer; "stored" says whether Huginn had the request by then
var reached = s["stored"] is not null;
JsonNode Unavailable() => new JsonObject { ["schemaVersion"] = "gamecult.fork.unavailable.v0", ["code"] = "unavailable", ["error"] = "unavailable: the outcome is not confirmed, try again with the same request_id", ["retryAfterSeconds"] = Clone(s["retry_after_seconds"]) };
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
JsonNode Replay(JsonNode stored) { var copy = Clone(stored)!; if (S(copy["answer"]!["code"] ?? "") == "stale_fork") copy["card"] = Clone(card); copy["counted_answer_id"] = Counted(); return copy; }
JsonNode response;
if (s["answerer"] is null) response = Door("unauthenticated");
else if (s["active"] is JsonValue activeValue && !activeValue.GetValue<bool>()) response = Door("forbidden");
else if (HasKey(b, "answerer", "authority")) response = Door("body_forbidden_field");
else if (b.Any(kv => !RequestKeys.Contains(kv.Key)) || (b["answer"] is JsonObject ao && ao.Any(kv => kv.Key is not ("choice" or "note")))) response = Door("body_unknown_field");
else if (Errors(ForkSchema, "answerRequest", b).Count > 0 || NoteRuleErrors("answerRequest", b).Any() || (!retracted && b["answer"] is null)) response = Door("body_invalid");
else if (down && !reached) response = Unavailable();
else if (seen.TryGetValue(S(s["answerer"]) + "|" + S(b["request_id"]), out var prior)) response = JsonNode.DeepEquals(prior.Body, b) ? Replay(prior.Response) : Door("request_id_conflict");
else if (S(b["fork_id"]) != S(card["fork_id"])) response = Door("fork_unknown");
else if (S(b["options_hash"]) != HashOf(card)) response = Refuse("stale_fork");
else if (!deciders.Contains(S(s["answerer"]))) response = Refuse("not_a_decider");
else if (S(card["status"]) == "closed") response = Refuse("fork_closed");
else if (retracted || b["retracts"] is not null)
{
var target = answers.FirstOrDefault(a => a is not null && b["retracts"] is not null && S(a["answer_id"]) == S(b["retracts"])
&& S(a["outcome"]) is "Counted" or "Revision" && S(a["options_hash"]) == S(b["options_hash"]));
if (!retracted || b["answer"] is not null || b["retracts"] is null || target is null) response = Refuse("bad_retraction");
else if (answers.Any(a => S(a!["outcome"]) == "Retraction" && S(a["retracts"]) == S(target["answer_id"]))) response = Refuse("already_retracted");
else response = Store("Retraction", S(target["answer_id"]), null);
}
else if (b["answer"]!["choice"] is not null && !card["options"]!.AsArray().Any(o => S(o!["id"]) == S(b["answer"]!["choice"]))) response = Refuse("choice_invalid");
else
{
var first = Counted();
response = first is null ? Store("Counted", null, null) : Store("Revision", first, null);
}
responses.Add(down && reached ? Unavailable() : response);
}
var record = new JsonObject
{
["schemaVersion"] = "gamecult.fork.record.v0", ["fork_id"] = Clone(card["fork_id"]), ["options_hash"] = HashOf(card), ["counted_answer_id"] = Counted(), ["answers"] = answers
};
return (responses, record);
}
[Test]
public void ForkCard_ARequestIdBelongsToItsAccount_AnotherAccountsIdenticalBodyGetsItsOwnAnswerNeverTheFirstOutcome()
{
var crossed = 0;
foreach (var sc in Fork["scenarios"]!.AsArray())
{
var first = new Dictionary<string, (string Answerer, JsonNode Result)>(); // request_id -> the account that first got a stored result and what it got
foreach (var step in sc!["steps"]!.AsArray())
{
if (S(step!["kind"]) != "answer" || step["response"]?["answer"] is null) continue;
var who = S(step["server"]!["answerer"]);
var result = step["response"]!;
Assert.That(S(result["answer"]!["answerer"]), Is.EqualTo(who), $"{S(sc["name"])}: a result is stamped with the account that asked");
var rid = S(step["body"]!["request_id"]);
if (!first.TryGetValue(rid, out var prior)) { first[rid] = (who, result); continue; }
if (prior.Answerer == who) continue;
crossed++;
Assert.That(S(result["answer"]!["answer_id"]), Is.Not.EqualTo(S(prior.Result["answer"]!["answer_id"])), $"{S(sc["name"])}: its own answer id");
Assert.That(JsonNode.DeepEquals(result, prior.Result), Is.False, $"{S(sc["name"])}: the first account's outcome is never returned");
}
}
Assert.That(crossed, Is.GreaterThanOrEqualTo(3), "the vectors carry the cross-account cases");
}
[Test]
public void ForkCard_EveryScenario_ReplaysToTheVectorsResponsesAndRecord_BytesIncluded()
{
var seen = new HashSet<string>();
var doorRefusals = 0;
var door = new HashSet<string> { "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict" };
foreach (var sc in Fork["scenarios"]!.AsArray())
{
var name = S(sc!["name"]);
Assert.That(HashOf(sc["card"]!), Is.EqualTo(S(sc["optionsHash"])), name);
Assert.That(CardRuleErrors(sc["card"]!), Is.Empty, name);
var steps = sc["steps"]!.AsArray();
var (responses, record) = Judge(sc["card"]!, steps);
Assert.That(responses.Count, Is.EqualTo(steps.Count), name);
for (var i = 0; i < steps.Count; i++)
Assert.That(JsonNode.DeepEquals(responses[i], steps[i]!["response"]), Is.True, $"{name} step {i}: {responses[i]?.ToJsonString()}");
Assert.That(JsonNode.DeepEquals(record, sc["record"]), Is.True, $"{name}: {record.ToJsonString()}");
Assert.That(Errors(ForkSchema, "record", record), Is.Empty, name);
Assert.That(HexOf(record), Is.EqualTo(S(sc["recordHex"])), name);
foreach (var step in steps)
{
var r = step!["response"];
if (r is null) continue;
Assert.That(Errors(ForkSchema, "answerResult", r).Count == 0 || Errors(ForkSchema, "refusal", r).Count == 0 || Errors(ForkSchema, "unavailable", r).Count == 0, Is.True, name);
if (r["code"] is not null)
{
seen.Add(S(r["code"]));
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
if (S(r["code"]) == "unavailable")
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.EqualTo(step["server"]!["stored"] is not null), $"{name} unavailable");
if (door.Contains(S(r["code"])))
{
doorRefusals++;
Assert.That(sc["record"]!["answers"]!.AsArray().Any(a => S(a!["answer_id"]) == S(step["server"]!["answer_id"])), Is.False, $"{name} stored a door refusal");
}
}
else seen.Add(S(r["answer"]!["code"] ?? r["answer"]!["outcome"]));
}
foreach (var a in sc["record"]!["answers"]!.AsArray()) seen.Add(S(a!["outcome"]));
}
Assert.That(doorRefusals, Is.GreaterThanOrEqualTo(8));
foreach (var want in new[] { "Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork",
"not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted" })
Assert.That(seen, Does.Contain(want));
}
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
private static bool AdmitRuling(JsonNode cite, JsonArray answers)
{
if (S(cite["authority"]) != "Operator") return true;
var cited = answers.FirstOrDefault(a => cite["answer_id"] is not null && S(a!["answer_id"]) == S(cite["answer_id"]));
return cited is not null && S(cited["outcome"]) == "Counted" && S(cited["authority"]) == "Operator";
}
[Test]
public void ForkCard_OperatorAuthority_NeedsACitedCountedAnswerRecord()
{
foreach (var c in Fork["rulingCites"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "rulingCite", c!["message"]), Is.Empty, S(c["name"]));
Assert.That(HexOf(c["message"]), Is.EqualTo(S(c["messagePackHex"])), S(c["name"]));
Assert.That(AdmitRuling(c["message"]!, c["answers"]!.AsArray()), Is.EqualTo(c["admit"]!.GetValue<bool>()), S(c["name"]));
}
}
// ---- transport: list, read, auth (fork-card.md "Transport") ----
private static IEnumerable<string> EntryRuleErrors(JsonNode e)
{
var card = e["card"]!; var record = e["record"]!;
foreach (var error in CardRuleErrors(card)) yield return error;
if (S(record["fork_id"]) != S(card["fork_id"])) yield return "record of another fork";
if (S(record["options_hash"]) != S(card["options_hash"])) yield return "record under another hash";
var answers = record["answers"]!.AsArray();
var counted = answers.Where(a => (S(a!["outcome"]) is "Counted" or "Revision") && S(a["options_hash"]) == S(card["options_hash"])
&& !answers.Any(r => S(r!["outcome"]) == "Retraction" && S(r["retracts"]) == S(a["answer_id"]))).Select(a => S(a!["answer_id"])).FirstOrDefault();
if ((record["counted_answer_id"] is null ? null : S(record["counted_answer_id"])) != counted) yield return "counted_answer_id is not derived";
}
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
private static JsonNode JudgeList(JsonArray entries, long ordinal, JsonNode request)
{
long Order(JsonNode? e) => e!["card"]!["order"]!.GetValue<long>();
var rows = entries.OrderByDescending(Order).ToList();
var want = rows.Where(e => request["status"] is null || (S(e!["card"]!["status"]) == "open") == (S(request["status"]) == "open")).ToList();
if (request["cursor"] is not null) { var below = long.Parse(S(request["cursor"])[1..]); want = want.Where(e => Order(e) < below).ToList(); }
var page = want.Take(request["limit"]!.GetValue<int>()).ToList();
var more = want.Count > page.Count && page.Count > 0;
return new JsonObject
{
["schemaVersion"] = "gamecult.fork.list.v0", ["mind"] = Clone(request["mind"]), ["stamp"] = $"s{ordinal}",
["open_count"] = rows.Count(e => S(e!["card"]!["status"]) == "open"), ["recorded_count"] = rows.Count(e => S(e!["card"]!["status"]) == "closed"),
["forks"] = new JsonArray(page.Select(Clone).ToArray()), ["next_cursor"] = more ? JsonValue.Create($"c{Order(page[^1])}") : null
};
}
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
private static string AuthJudge(string route, JsonNode? token, bool huginnDown)
{
if (token is null || S(token["audience"]) != "bifrost-desk") return "unauthenticated";
var need = route == "answer" ? "desk.answer" : "desk.read";
if (!token["scopes"]!.AsArray().Any(x => S(x) == need)) return "insufficient_scope";
if (!token["active"]!.GetValue<bool>()) return "forbidden";
return huginnDown ? "unavailable" : "ok";
}
private static string PercentDecode(string s)
{
var bytes = new List<byte>();
for (var i = 0; i < s.Length;)
{
if (s[i] == '%' && i + 2 < s.Length && Uri.IsHexDigit(s[i + 1]) && Uri.IsHexDigit(s[i + 2])) { bytes.Add(Convert.ToByte(s.Substring(i + 1, 2), 16)); i += 3; continue; }
var rune = System.Text.Rune.GetRuneAt(s, i);
bytes.AddRange(Encoding.UTF8.GetBytes(rune.ToString())); i += rune.Utf16SequenceLength;
}
return new UTF8Encoding(false, true).GetString(bytes.ToArray()); // throws DecoderFallbackException (an ArgumentException) on invalid UTF-8
}
// The {mind} segment is decoded once and compared, ordinally, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
private static (string Code, string? Opened, string? StoredIn) MindRoute(JsonNode config, string route, string segment, string? fork, JsonNode? token)
{
var auth = AuthJudge(route, token, false);
if (auth != "ok") return (auth, null, null);
string mind;
try { mind = PercentDecode(segment); } catch (ArgumentException) { return ("fork_unknown", null, null); } // bytes that are not UTF-8 are a miss
var minds = config["minds"]!.AsObject();
if (!minds.TryGetPropertyValue(mind, out var socket)) return ("fork_unknown", null, null);
if (route != "list" && !config["forks"]![mind]!.AsArray().Any(f => S(f) == fork)) return ("fork_unknown", S(socket), null);
return ("ok", S(socket), route == "answer" ? mind : null);
}
[Test]
public void ForkTransport_MindSegment_IsOnlyComparedWithTheConfiguredList_AnUnknownMindIsTheUnknownForkRefusal()
{
var config = Fork["transport"]!["mindRoutes"]!;
var refusals = Fork["doorRefusals"]!.AsArray().ToDictionary(r => S(r!["name"]), r => r!);
var byName = new Dictionary<string, (string Code, string? Opened, string? StoredIn)>();
foreach (var c in config["cases"]!.AsArray())
{
var got = MindRoute(config, S(c!["route"]), S(c["mind"]), c["fork_id"] is null ? null : S(c["fork_id"]), c["token"]);
byName[S(c["name"])] = got;
Assert.That(got.Code, Is.EqualTo(S(c["expect"])), S(c["name"]));
Assert.That(got.Opened, Is.EqualTo(c["opened"] is null ? null : S(c["opened"])), $"{S(c["name"])}: the socket opened");
Assert.That(got.StoredIn, Is.EqualTo(c["stored_in"] is null ? null : S(c["stored_in"])), $"{S(c["name"])}: where an answer was stored");
Assert.That(c["httpStatus"]!.GetValue<int>(), Is.EqualTo(got.Code == "ok" ? 200 : refusals[got.Code]["httpStatus"]!.GetValue<int>()), S(c["name"]));
if (got.Code == "fork_unknown")
{
var error = S(refusals["fork_unknown"]["message"]!["error"]);
Assert.That(error.Contains(S(c["mind"])), Is.False, $"{S(c["name"])}: the refusal does not name the Mind");
Assert.That(c["fork_id"] is not null && error.Contains(S(c["fork_id"])), Is.False, $"{S(c["name"])}: the refusal does not name the fork");
}
}
foreach (var pair in config["sameResponse"]!.AsArray())
{
var a = byName[S(pair![0])]; var b = byName[S(pair[1])];
Assert.That(a.Code, Is.EqualTo(b.Code));
string Built(string code) => HexOf(new JsonObject { ["schemaVersion"] = "gamecult.fork.refusal.v0", ["code"] = code, ["error"] = $"{code}: {Tails[code]}" });
Assert.That(Built(a.Code), Is.EqualTo(Built(b.Code)).And.EqualTo(S(refusals[a.Code]["messagePackHex"])), $"{S(pair[0])} and {S(pair[1])} answer with the same bytes, built from the spec's rule");
}
Assert.That(byName.Values.Count(v => v.Code == "fork_unknown" && v.Opened is null), Is.GreaterThanOrEqualTo(39), "every bad segment opens nothing");
Assert.That(byName.Values.Where(v => v.StoredIn is not null).Select(v => v.StoredIn).Distinct().Count(), Is.EqualTo(2), "an answer is stored in the route's Mind only, and both Minds take one");
}
[Test]
public void ForkTransport_RequestsAndEntriesValidateEncodeToTheVectorBytes_AndEveryNegativeIsRefused()
{
var t = Fork["transport"]!;
foreach (var (section, def) in new[] { ("listRequest", "listRequest"), ("readRequest", "readRequest"), ("entry", "entry") })
{
foreach (var v in t[section]!["valid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, def, v!["message"]), Is.Empty, S(v["name"]));
if (def == "entry") Assert.That(EntryRuleErrors(v["message"]!), Is.Empty, S(v["name"]));
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
foreach (var v in t[section]!["invalid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, def, v!["message"]).Count > 0 || (def == "entry" && EntryRuleErrors(v["message"]!).Any()), Is.True, $"{S(v["name"])}: {S(v["why"])}");
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
}
foreach (var v in t["listInvalid"]!.AsArray())
{
Assert.That(Errors(ForkSchema, "list", v!["message"]), Is.Not.Empty, $"{S(v["name"])}: {S(v["why"])}");
Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"]));
}
}
[Test]
public void ForkTransport_EveryListRequest_ReplaysToTheVectorsResponse_NewestFirst_PagedByTheCursor()
{
foreach (var sc in Fork["transport"]!["listScenarios"]!.AsArray())
{
var name = S(sc!["name"]);
var entries = sc["entries"]!.AsArray();
foreach (var e in entries) Assert.That(EntryRuleErrors(e!), Is.Empty, name);
JsonNode? previous = null;
foreach (var step in sc["steps"]!.AsArray())
{
var response = JudgeList(entries, sc["ordinal"]!.GetValue<long>(), step!["request"]!);
Assert.That(JsonNode.DeepEquals(response, step["response"]), Is.True, $"{name}: {response.ToJsonString()}");
Assert.That(Errors(ForkSchema, "list", response), Is.Empty, name);
Assert.That(HexOf(response), Is.EqualTo(S(step["responseHex"])), name);
var orders = response["forks"]!.AsArray().Select(f => f!["card"]!["order"]!.GetValue<long>()).ToList();
Assert.That(orders, Is.Ordered.Descending, name);
if (previous is not null) Assert.That(S(step["request"]!["cursor"]), Is.EqualTo(S(previous["next_cursor"])), $"{name}: the second request follows the first page's cursor");
previous = response;
}
}
}
[Test]
public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing()
{
var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!;
var codes = schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S).ToList();
var seen = new HashSet<string>();
foreach (var c in Fork["transport"]!["authCases"]!.AsArray())
{
var got = AuthJudge(S(c!["route"]), c["token"], c["huginn_down"]!.GetValue<bool>());
Assert.That(got, Is.EqualTo(S(c["expect"])), S(c["name"]));
if (got is not ("ok" or "unavailable")) Assert.That(codes, Does.Contain(got), S(c["name"]));
seen.Add(S(c["route"]) + ":" + got);
}
Assert.That(seen, Does.Contain("list:insufficient_scope").And.Contain("answer:insufficient_scope").And.Contain("read:forbidden").And.Contain("list:unavailable").And.Contain("read:unavailable").And.Contain("answer:unavailable").And.Contain("read:unauthenticated").And.Contain("answer:ok"));
}
}
}

View file

@ -180,10 +180,9 @@ namespace GameCult.Networking.Tests
if (instance.ValueKind == JsonValueKind.String) if (instance.ValueKind == JsonValueKind.String)
{ {
var value = instance.GetString()!; var value = instance.GetString()!;
var length = value.EnumerateRunes().Count(); // JSON Schema counts characters as code points, as Ajv does, not UTF-16 units if (schema.TryGetProperty("minLength", out var minLength) && value.Length < minLength.GetInt32())
if (schema.TryGetProperty("minLength", out var minLength) && length < minLength.GetInt32())
errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}"); errors.Add($"{path}: string shorter than minLength {minLength.GetInt32()}");
if (schema.TryGetProperty("maxLength", out var maxLength) && length > maxLength.GetInt32()) if (schema.TryGetProperty("maxLength", out var maxLength) && value.Length > maxLength.GetInt32())
errors.Add($"{path}: string longer than maxLength {maxLength.GetInt32()}"); errors.Add($"{path}: string longer than maxLength {maxLength.GetInt32()}");
if (schema.TryGetProperty("pattern", out var pattern)) if (schema.TryGetProperty("pattern", out var pattern))
{ {

View file

@ -1,68 +0,0 @@
# sprite-silhouette
Turns a sprite's alpha channel into a simplified polygon in percent coordinates, for CSS
`shape-outside: polygon()`, so text wraps around the figure instead of its rectangle. Zero
dependencies: Node (tested on 24; `node:zlib` does the PNG inflate). Entry point: `silhouette.mjs`.
Live and tested; there is no packaging or release for it, it is a repo tool.
## First call
```
node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses --out silhouettes.json
node --test tools/sprite-silhouette/silhouette.test.mjs
```
The first argument is a poses directory holding `catalogue.json` (the cast-art catalogue); the
tool reads it and every PNG it names, and writes nothing there. Output is a JSON object keyed by
pose id, `mind/label` in catalogue order (`void/neutral`, `eureka/probe`, ...):
```json
{ "void/neutral": { "width": 605, "height": 461,
"points": [[42.98, 6.72], [49.59, 8.24], "..."],
"css": "polygon(42.98% 6.72%, 49.59% 8.24%, ...)" } }
```
Use the `css` string as the value of `shape-outside` on a floated image of the same aspect ratio
(percentages are of the image's box):
```css
img.pose { float: left; shape-outside: polygon(42.98% 6.72%, 49.59% 8.24%, ...); shape-margin: 0.5em; }
```
An empty sprite (nothing at or above the threshold) gets `points: []` and `css: "none"`.
From code: `silhouette(pngBuffer, { threshold, tolerance })` in `silhouette.mjs`.
## Options
- `--threshold 0.35`: a pixel is inside when `alpha/255 >= threshold` (0.35 is the Void mock-up's value).
- `--tolerance 1.5`: Douglas-Peucker distance in source pixels; the simplified polygon stays within
this of the traced edge in both directions (tested). 0 keeps every corner.
- `--out file`: write there instead of stdout.
## What it does and does not trace
Threshold, then the outer boundary of the largest 8-connected shape, by walking the pixel edges
(so vertices sit on pixel corners and the walk is exact), then simplification of that closed ring.
Holes are ignored (wrapping text goes around a figure, not through its gaps), and smaller
disconnected islands (a floating hand, a stray sparkle) are dropped: `shape-outside` takes one
polygon. The contour is a staircase of pixel edges, so below about 1 px the tolerance only
reproduces the staircase; there is no sub-pixel interpolation of the alpha ramp.
Input: 8-bit non-interlaced PNG (RGBA, grey+alpha, palette with tRNS, or opaque RGB/grey, which is
a rectangle). Anything else is refused with its name.
## Prior art
- **Marching squares** (Lorensen and Cline's cubes, 2D case) interpolates along the alpha ramp for
sub-pixel contours and one pass emits every contour, holes included. Not used: the pose art is
anti-aliased over a pixel or two, a 1.5 px tolerance swallows that, and we want exactly one outer ring.
- **Potrace** (Selinger, 2003) fits optimal polygons and then Bezier curves to a bitmap. CSS
`polygon()` has no curves, so only its idea of an optimal polygon applies; this tool keeps the
simpler greedy Douglas-Peucker and measures its error directly.
- **Douglas-Peucker** (1973; Ramer 1972) is the simplifier: keep the vertex farthest from the chord
if it is beyond the tolerance, recurse. Its guarantee (every dropped vertex within the tolerance
of the result) is what the tests assert. It is not topology-preserving; on all 99 current poses at
the defaults no output self-intersects (checked when this was written, not asserted by the suite).
- **Boundary following** (Moore-neighbour / square tracing) is the contour walk, with the
8-connected rule so diagonally touching pixels stay one shape.

View file

@ -1,82 +0,0 @@
// Minimal PNG reader: the alpha plane only. 8-bit, non-interlaced (every pose in cast-art is RGBA8).
// Anything else is refused by name, never guessed at.
import { inflateSync } from "node:zlib";
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
/** @returns {{ width: number, height: number, alpha: Uint8Array }} alpha is row-major, 0..255. */
export function decodeAlpha(buffer) {
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
let header = null;
let palette = null;
let trns = null;
const idat = [];
for (let at = 8; at + 8 <= buffer.length; ) {
const length = buffer.readUInt32BE(at);
const type = buffer.toString("latin1", at + 4, at + 8);
const body = buffer.subarray(at + 8, at + 8 + length);
if (body.length !== length) throw new Error(`truncated ${type} chunk`);
at += 12 + length;
if (type === "IHDR") {
header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], interlace: body[12] };
} else if (type === "PLTE") palette = body;
else if (type === "tRNS") trns = body;
else if (type === "IDAT") idat.push(body);
else if (type === "IEND") break;
}
if (!header) throw new Error("PNG has no IHDR");
const { width, height, depth, color, interlace } = header;
if (depth !== 8) throw new Error(`unsupported PNG bit depth ${depth} (8 only)`);
if (interlace !== 0) throw new Error("unsupported PNG: interlaced");
const channels = CHANNELS[color];
if (!channels) throw new Error(`unsupported PNG color type ${color}`);
if (color === 3 && !palette) throw new Error("indexed PNG has no PLTE");
const raw = inflateSync(Buffer.concat(idat));
const stride = width * channels;
if (raw.length !== (stride + 1) * height) throw new Error("PNG pixel data has the wrong length");
const alpha = new Uint8Array(width * height);
let prev = new Uint8Array(stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
unfilter(filter, line, prev, channels);
for (let x = 0; x < width; x++) {
const p = x * channels;
let a = 255;
if (color === 6) a = line[p + 3];
else if (color === 4) a = line[p + 1];
else if (color === 3) a = trns && line[p] < trns.length ? trns[line[p]] : 255;
else if (trns && color === 0 && line[p] === trns.readUInt16BE(0)) a = 0;
else if (trns && color === 2 && line[p] === trns.readUInt16BE(0) && line[p + 1] === trns.readUInt16BE(2) && line[p + 2] === trns.readUInt16BE(4)) a = 0;
alpha[y * width + x] = a;
}
prev = line;
}
return { width, height, alpha };
}
function unfilter(filter, line, prev, bpp) {
for (let i = 0; i < line.length; i++) {
const a = i >= bpp ? line[i - bpp] : 0;
const b = prev[i];
const c = i >= bpp ? prev[i - bpp] : 0;
let add;
switch (filter) {
case 0: add = 0; break;
case 1: add = a; break;
case 2: add = b; break;
case 3: add = (a + b) >> 1; break;
case 4: {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
break;
}
default: throw new Error(`bad PNG filter ${filter}`);
}
line[i] = (line[i] + add) & 255;
}
}

View file

@ -1,61 +0,0 @@
#!/usr/bin/env node
// Sprite alpha silhouette -> simplified polygon in percent coordinates for CSS shape-outside: polygon().
// First call: node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses > silhouettes.json
import { readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import { decodeAlpha } from "./png.mjs";
import { largestContour, mask, simplifyRing } from "./trace.mjs";
export const DEFAULTS = { threshold: 0.35, tolerance: 1.5 };
const pct = (v, total) => Math.round((v / total) * 10000) / 100;
/** @returns {{ width: number, height: number, points: number[][], css: string }} points are [x%, y%]; css is "none" for an empty sprite. */
export function silhouette(png, { threshold = DEFAULTS.threshold, tolerance = DEFAULTS.tolerance } = {}) {
const { width, height, alpha } = decodeAlpha(png);
const ring = largestContour(mask(alpha, threshold), width, height);
if (!ring) return { width, height, points: [], css: "none" };
const points = simplifyRing(ring, tolerance).map(([x, y]) => [pct(x, width), pct(y, height)]);
const css = `polygon(${points.map(([x, y]) => `${x}% ${y}%`).join(", ")})`;
return { width, height, points, css };
}
/** Pose ids are `mind/label`, in catalogue order; the value is the path under the poses root. */
export function poseFiles(catalogue) {
const files = {};
for (const [mind, entry] of Object.entries(catalogue.minds)) {
for (const group of [entry.shared, entry.signatures]) {
for (const [label, path] of Object.entries(group ?? {})) files[`${mind}/${label}`] = path;
}
}
return files;
}
export function silhouettes(root, options) {
const catalogue = JSON.parse(readFileSync(join(root, "catalogue.json"), "utf8"));
const out = {};
for (const [id, path] of Object.entries(poseFiles(catalogue))) out[id] = silhouette(readFileSync(join(root, path)), options);
return out;
}
function main(argv) {
const options = { ...DEFAULTS };
let out = null, root = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--threshold") options.threshold = Number(argv[++i]);
else if (argv[i] === "--tolerance") options.tolerance = Number(argv[++i]);
else if (argv[i] === "--out") out = argv[++i];
else if (argv[i].startsWith("--")) throw new Error(`unknown option ${argv[i]}`);
else root = argv[i];
}
if (!root || !(options.threshold > 0 && options.threshold <= 1) || !(options.tolerance >= 0)) {
console.error("usage: silhouette.mjs <poses-dir> [--threshold 0.35] [--tolerance 1.5] [--out file.json]");
process.exit(2);
}
const text = JSON.stringify(silhouettes(root, options), null, 1) + "\n";
if (out) writeFileSync(out, text);
else process.stdout.write(text);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2));

View file

@ -1,133 +0,0 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import test from "node:test";
import { deflateSync } from "node:zlib";
import { decodeAlpha } from "./png.mjs";
import { silhouette, silhouettes } from "./silhouette.mjs";
import { largestContour, mask, maxDistanceToRing, simplifyRing } from "./trace.mjs";
// An RGBA8 PNG built from an alpha function, every row encoded with the given PNG filter.
function crc32(buf) {
let c, crc = ~0;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 255;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
crc = (crc >>> 8) ^ c;
}
return ~crc >>> 0;
}
function chunk(type, body) {
const head = Buffer.alloc(8);
head.writeUInt32BE(body.length, 0);
head.write(type, 4, "latin1");
const tail = Buffer.alloc(4);
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
return Buffer.concat([head, body, tail]);
}
function png(width, height, alphaAt, { filter = 0 } = {}) {
const rows = [];
let prev = Buffer.alloc(width * 4);
for (let y = 0; y < height; y++) {
const line = Buffer.alloc(width * 4);
for (let x = 0; x < width; x++) line.set([200, 10 * (x % 20), 30, alphaAt(x, y)], x * 4);
const out = Buffer.alloc(width * 4 + 1);
out[0] = filter;
for (let i = 0; i < line.length; i++) {
const a = i >= 4 ? line[i - 4] : 0, b = prev[i], c = i >= 4 ? prev[i - 4] : 0;
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
const pred = [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter];
out[i + 1] = (line[i] - pred) & 255;
}
rows.push(out);
prev = line;
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr), chunk("IDAT", deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0)),
]);
}
const disc = (cx, cy, r) => (x, y) => ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r ? 255 : 0);
test("decodes alpha through every PNG row filter", () => {
const f = (x, y) => (x * 7 + y * 13) % 256;
for (const filter of [0, 1, 2, 3, 4]) {
const { width, height, alpha } = decodeAlpha(png(9, 7, f, { filter }));
assert.deepEqual([width, height], [9, 7]);
for (let y = 0; y < 7; y++) for (let x = 0; x < 9; x++) assert.equal(alpha[y * 9 + x], f(x, y), `filter ${filter} at ${x},${y}`);
}
});
test("refuses what it cannot read", () => {
assert.throws(() => decodeAlpha(Buffer.from("nope")), /not a PNG/);
const bad = png(2, 2, () => 255);
bad[24] = 16; // IHDR bit depth
assert.throws(() => decodeAlpha(bad), /bit depth 16/);
});
test("a fully transparent sprite has no shape", () => {
const s = silhouette(png(8, 8, () => 0));
assert.deepEqual(s.points, []);
assert.equal(s.css, "none");
});
test("a fully opaque sprite is its own bounding box", () => {
const s = silhouette(png(8, 4, () => 255));
assert.deepEqual(s.points, [[0, 0], [100, 0], [100, 100], [0, 100]]);
assert.equal(s.css, "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)");
});
test("threshold is alpha/255 >= 0.35: 89 is out, 90 is in", () => {
assert.deepEqual(silhouette(png(4, 4, () => 89)).points, []);
assert.equal(silhouette(png(4, 4, () => 90)).points.length, 4);
assert.deepEqual(silhouette(png(4, 4, () => 89), { threshold: 0.3 }).points.length, 4);
});
test("simplified shape stays within tolerance of the traced edge, both ways, and is smaller", () => {
const { width, height, alpha } = decodeAlpha(png(120, 100, disc(60, 50, 40)));
const ring = largestContour(mask(alpha, 0.35), width, height);
for (const tolerance of [0.5, 1.5, 4]) {
const simple = simplifyRing(ring, tolerance);
assert.ok(simple.length < ring.length && simple.length >= 4);
assert.ok(maxDistanceToRing(ring, simple) <= tolerance + 1e-9, `ring to simplified at ${tolerance}`);
assert.ok(maxDistanceToRing(simple, ring) <= tolerance + 1e-9, `simplified to ring at ${tolerance}`);
}
// And the traced edge is the true edge to within a pixel: every ring vertex sits within 1px of the disc's circle.
for (const [x, y] of ring) assert.ok(Math.abs(Math.hypot(x - 60, y - 50) - 40) <= 1.0, `${x},${y}`);
});
test("only the outside is traced: a hole is ignored", () => {
const ring = (x, y) => (Math.max(Math.abs(x - 10), Math.abs(y - 10)) <= 8 && Math.max(Math.abs(x - 10), Math.abs(y - 10)) > 3 ? 255 : 0);
const s = silhouette(png(20, 20, ring), { tolerance: 0 });
const xs = s.points.map((p) => p[0]), ys = s.points.map((p) => p[1]);
assert.deepEqual([Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)], [10, 95, 10, 95]);
assert.equal(s.points.length, 4);
});
test("pixels that only touch diagonally are one shape; a smaller island is dropped", () => {
const dots = (x, y) => (x === y && x < 4 ? 255 : 0);
const diag = silhouette(png(8, 8, dots), { tolerance: 0 });
const xs = diag.points.map((p) => p[0]);
assert.equal(Math.max(...xs), 50); // x 0..4 of 8
const island = (x, y) => ((x < 5 && y < 5) || (x === 8 && y === 8) ? 255 : 0);
const s = silhouette(png(10, 10, island));
assert.deepEqual(s.points, [[0, 0], [50, 0], [50, 50], [0, 50]]);
});
test("deterministic: same bytes, same polygon; coordinates are percent within the image", () => {
const bytes = png(90, 70, disc(45, 35, 30));
const a = silhouette(bytes), b = silhouette(Buffer.from(bytes));
assert.deepEqual(a, b);
for (const [x, y] of a.points) assert.ok(x >= 0 && x <= 100 && y >= 0 && y <= 100);
assert.match(a.css, /^polygon\((\d+(\.\d+)?% \d+(\.\d+)?%)(, \d+(\.\d+)?% \d+(\.\d+)?%){3,}\)$/);
});
const POSES = "/opt/commons/cast-art/poses";
test("every pose in the shared cast-art catalogue gets a polygon", { skip: !existsSync(`${POSES}/catalogue.json`) }, () => {
const all = silhouettes(POSES);
const ids = Object.keys(all);
assert.ok(ids.length > 90 && ids.includes("void/neutral"));
for (const id of ids) assert.ok(all[id].points.length >= 4 && all[id].points.length < 400, id);
assert.deepEqual(silhouettes(POSES), all);
});

View file

@ -1,109 +0,0 @@
// Silhouette of an alpha plane: threshold -> outer contour of the largest 8-connected shape -> Douglas-Peucker.
// Coordinates are pixel-corner lattice points: x in 0..width, y in 0..height.
/** A pixel is inside when alpha/255 >= threshold. */
export function mask(alpha, threshold) {
const m = new Uint8Array(alpha.length);
for (let i = 0; i < alpha.length; i++) m[i] = alpha[i] / 255 >= threshold ? 1 : 0;
return m;
}
/** Outer contour (lattice vertices at turns, clockwise on screen) of the 8-connected component with the most pixels, or null. */
export function largestContour(m, width, height) {
const seen = new Uint8Array(m.length);
let best = null;
for (let start = 0; start < m.length; start++) {
if (!m[start] || seen[start]) continue;
// Raster order: `start` is the component's first pixel, so the pixels above and up-left of it are empty
// or belong to another component.
let count = 0;
const stack = [start];
seen[start] = 1;
while (stack.length) {
const p = stack.pop();
count++;
const px = p % width, py = (p / width) | 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const x = px + dx, y = py + dy;
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const q = y * width + x;
if (m[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
}
}
if (!best || count > best.count) best = { count, start };
}
return best && traceOuter(m, width, height, best.start % width, (best.start / width) | 0);
}
// Directions: 0 east, 1 south, 2 west, 3 north. The shape is kept on the walker's right, so an
// outer boundary comes out clockwise; at each corner a filled left-ahead pixel turns left (which
// joins pixels that only touch diagonally), else a filled right-ahead pixel goes straight, else right.
const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1];
const AHEAD_LEFT = [[0, -1], [0, 0], [-1, 0], [-1, -1]];
const AHEAD_RIGHT = [[0, 0], [-1, 0], [-1, -1], [0, -1]];
function traceOuter(m, width, height, px, py) {
const at = (x, y) => x >= 0 && y >= 0 && x < width && y < height && m[y * width + x] === 1;
const vertices = [];
let x = px, y = py, dir = 3, first = true; // arrive at the start corner heading north, so it is counted as a turn
for (;;) {
const left = at(x + AHEAD_LEFT[dir][0], y + AHEAD_LEFT[dir][1]);
const right = at(x + AHEAD_RIGHT[dir][0], y + AHEAD_RIGHT[dir][1]);
const turn = left ? 3 : right ? 0 : 1;
const next = (dir + turn) & 3;
// Back at the start corner and about to leave east: the ring is closed.
if (!first && x === px && y === py && next === 0) break;
first = false;
if (turn !== 0) vertices.push([x, y]);
dir = next;
x += DX[dir]; y += DY[dir];
}
return vertices;
}
/** Douglas-Peucker on a closed ring: split at the vertex farthest from the first, simplify both halves. */
export function simplifyRing(ring, tolerance) {
if (ring.length <= 3 || tolerance <= 0) return ring.slice();
let far = 0, best = -1;
for (let i = 1; i < ring.length; i++) {
const d = (ring[i][0] - ring[0][0]) ** 2 + (ring[i][1] - ring[0][1]) ** 2;
if (d > best) { best = d; far = i; }
}
const a = dp(ring.slice(0, far + 1), tolerance);
const b = dp(ring.slice(far).concat([ring[0]]), tolerance);
return a.slice(0, -1).concat(b.slice(0, -1));
}
function dp(points, tolerance) {
const keep = new Uint8Array(points.length);
keep[0] = keep[points.length - 1] = 1;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [lo, hi] = stack.pop();
let worst = -1, at = -1;
for (let i = lo + 1; i < hi; i++) {
const d = distanceToSegment(points[i], points[lo], points[hi]);
if (d > worst) { worst = d; at = i; }
}
if (worst > tolerance) { keep[at] = 1; stack.push([lo, at], [at, hi]); }
}
return points.filter((_, i) => keep[i]);
}
export function distanceToSegment(p, a, b) {
const vx = b[0] - a[0], vy = b[1] - a[1];
const len2 = vx * vx + vy * vy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2));
return Math.hypot(p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
}
/** Largest distance from any point of ring `a`'s edges' vertices to ring `b`'s edges. */
export function maxDistanceToRing(points, ring) {
let worst = 0;
for (const p of points) {
let d = Infinity;
for (let i = 0; i < ring.length; i++) d = Math.min(d, distanceToSegment(p, ring[i], ring[(i + 1) % ring.length]));
worst = Math.max(worst, d);
}
return worst;
}