diff --git a/docs/cultnet-rudp-ack-cut.md b/docs/cultnet-rudp-ack-cut.md index 30d6089f..9f07341c 100644 --- a/docs/cultnet-rudp-ack-cut.md +++ b/docs/cultnet-rudp-ack-cut.md @@ -1,50 +1,64 @@ # CultNet RUDP: Acknowledged means delivered, one watermark owns ordered delivery, and a write belongs to its session -Status: cut map, Imagination pass 2, 2026-09-30, committed by Self. Pass 2 re-maps every cut against -`main` at `3bf1c0c` and applies the operator rulings below. +Status: cut map, Imagination pass 3, 2026-09-30. Pass 3 maps the four rulings below, re-maps Cuts 1c and +4, and adds Cut F (FORWARD-TSN), Cuts 6a-6c (receipts), Cut 7 (expiry, if Q-A8), Cut K (Kotlin), Cut 2c (the +C# fragment bound) and Cut D (a failed send to one peer stays that peer's loss). The text of Cuts 1, 1b, 2 +and 3 is pass 2's; Hands are executing Cuts 1 and 1b (`hands/ack-cut1`) and Cut 2 (`hands/ack-cut2`) from +it. One thing moves for them: Cut 2 merges only together with Cut F (the Cut 2 Soul finding below). **Rulings (operator, 2026-09-30).** -- **Q-A1 (a):** Delivered is derived at the sender from a correct receiver's cumulative watermark. The wire - does not change. +- **Q-A1 (a):** Delivered is derived at the sender from a correct receiver's cumulative watermark. The + acknowledgement wire does not change. (Cut F adds one notice to the wire; it carries abandonment, not + delivery.) - **Q-A2:** "I think this is telling us we don't actually need both received and delivered." - There is one public meaning: Acknowledged means delivered. - Receipts go Pending, then Acknowledged (delivered), or Invalidated. No public Received state exists. - Retransmission still stops on each packet's own ack, so resending stays bounded. - Flush waits for Acknowledged. -- **Scope added from the stray-packet Soul passes 3 and 4:** session generation. A restarted client on the - same endpoint and payload is locked out. A reconnect on the same transport treats a new server session's - frames as duplicates. The ending paths disagree about owed writes. One rule: a write belongs to the - session it was issued in. Pass 2 maps this as **Cut 1b** (the ending rule) and **Cut 1c** (how a new +- **Scope added from the stray-packet Soul passes 3 and 4:** session generation. One rule: a write belongs + to the session it was issued in. Mapped as **Cut 1b** (the ending rule) and **Cut 1c** (how a new generation is recognised), separate from Cut 1 (§8 says why). -- **Q-A3 B (ruled after pass 2):** FORWARD-TSN now (RFC 3758). On expiry the sender sends an - abandonment notice (Ack-typed, flag `0x10`, ignored by old peers); new receivers treat the sequence as - received with no frame and acknowledge it; the notice's resend has its own bound. This is a new wire - cut in every receiver, landing before Cut 4. The recommendation was A; the operator chose B. -- **Q-A4 A (ruled after pass 2):** port receipts (Pending, Acknowledged, Invalidated, `WaitAcknowledged`) - to C#, TypeScript, Python and Kotlin in this campaign, after Cut 4. The recommendation was B. -- **Q-A5 A (ruled after pass 2):** Kotlin is in, as trailing Cut K, verified on a Kotlin image added to - Yggdrasil first. -- **Q-A6 A (ruled after pass 2):** the Connect's sequence identifies the session; the default initial - sequence becomes random in [1, 2^31), as TCP's ISN. No wire change; the public default changes. -- **Pending:** the cut sections below still read these four as open. Imagination pass 3 maps the - FORWARD-TSN cut, the receipts port and the Kotlin image, and re-sequences Cuts 1c, 4 and K. +- **Q-A3 B:** FORWARD-TSN (RFC 3758) now. On expiry the sender sends an abandonment notice, Ack-typed with + flag `0x10` so old peers ignore it. New receivers treat the sequence as received with no frame and + acknowledge it. The notice's resend has its own bound. A new wire cut in every receiver, before Cut 4. + Mapped as **Cut F** (§6). The recommendation was A; the operator chose B. (Supersedes pass 2's Cut 4 + expiry half, which assumed A.) +- **Q-A4 A:** port per-send receipts (Pending, Acknowledged, Invalidated, and a wait for Acknowledged) to C#, + TypeScript, Python and Kotlin in this campaign, after Cut 4. Mapped as **Cuts 6a-6c** and **K4** (§9). + The recommendation was B. +- **Q-A5 A:** Kotlin is in, as trailing **Cut K**, verified on a Kotlin image added to Yggdrasil first + (Cut K0). +- **Q-A6 A:** the Connect's sequence identifies the session; the default initial sequence becomes random in + [1, 2^31), as TCP's ISN. No wire change; the public default changes. Cut 1c is no longer blocked on a + question. +- **Cut 2 Soul finding (2026-09-30):** Cut 2's never-evict rule refuses a session whose sender expires + fragmented reliable frames (the default Rust `media` channel). Pass 3 absorbs it: Cut F's receiver drops + every incomplete fragment set a notice covers; Cut 2 merges only together with Cut F; C# gets the bound + it never had (Cut 2c). What a receiver does with a sender that never sends notices is **Q-A9**. +- **Open:** Q-A7 (the notice names one sequence, not a cumulative point), Q-A8 (reliable expiry in the + four runtimes that lack it) and Q-A9 (a capability bit, so Cut 2's rule applies only to peers that send + notices). Each has a recommendation. Q-A7 and Q-A9 block Cut F and with it Cut 2's merge; Q-A8 blocks + only Cut 7. -- **Body.** CultLib `main` at `3bf1c0c` ("Merge RUDP stray-packet hardening across all four runtimes"). - Every `file:line` is against it unless it names another revision. - - Since pass 1's base `d85877a7`, only `rudp.rs` and `rudp.ts` moved among the transport files. - `CultNetTransport.cs`, `transport.py`, `CultMesh.kt`, `cultmesh-rs`, `cultmesh-ts`, `peer.ts`, the two - TS publishers and `docs/cultnet-transport-parity.md` are byte-identical to `d85877a7`. - - `rudp.rs` moved +6 lines from `:283` and +12 from `:596`. `rudp.ts` moved only after `:824`. - - Odin pins `3bf1c0c` (`Odin crates/odin-core/Cargo.toml:14-17`). -- **Base of every cut.** `main` at `3bf1c0c` or later. +- **Body.** CultLib `main` at `e9469cb3`. Every transport file is byte-identical to `3bf1c0c` there + (`git diff 3bf1c0c e9469cb3 -- src packages` is empty), so every `file:line` below is against both unless + it names another revision. + - Odin `origin/main` (`5c37860`) pins `3bf1c0ce` (`crates/odin-core/Cargo.toml:14-17`). A stale local + checkout at `379b826` still shows `a8aeddac`; read Odin's pin from `origin/main`. + - Pass 3 probes: `3e2c7efb` (FN) and `c6f1012c` (CAP) on the Yggdrasil mirror `probe3` (§ Probe + evidence, pass 3). +- **Base of every cut.** `main` at `e9469cb3` or later. - **Scope.** The RUDP session and socket transports in all five runtimes: C# `src/GameCult.Networking/CultNetTransport.cs`, Rust `packages/cultnet-rs/src/rudp.rs` (plus `packages/cultmesh-rs`), TypeScript `packages/cultnet-ts/src/rudp.ts` (plus the two hand-rolled - publishers), Python `packages/cultnet-py/src/cultnet_py/transport.py`, and Kotlin - `packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt`. **Q-A5** decides whether - Kotlin is in. Until it is ruled, every cut is mapped for four runtimes and Kotlin trails (§ Sequencing). -- **Wire.** This map changes no byte on the wire (§1). Cut 1c changes the default initial sequence, which - is a value, not a format. + publishers and `cultmesh-ts`'s document server), Python `packages/cultnet-py/src/cultnet_py/transport.py` + (plus `cultmesh-py`'s server), and Kotlin + `packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt` (Cut K). +- **Wire.** One addition, in Cut F: an Ack packet with flag bit 4 (`0x10`) is an abandonment notice for the + sequence in its sequence field. Every current decoder ignores the bit and every current session ignores + an Ack's sequence field (P7, re-probed as FN in pass 3 in all five runtimes). If Q-A9 is B, the same bit + on Connect and Accept advertises support (probe CAP: old peers ignore it there too). Nothing else changes + on the wire. Cut 1c changes the default initial sequence, which is a value, not a format. ## The ends (operator, 2026-09-30) @@ -117,10 +131,17 @@ And places where the sender loses track, or is locked out, without the receiver' |---|---|---|---| | G1 | A restarted client on the same endpoint and Connect payload gets an Ack, never an Accept, and stays unconnected | Rust hub, C# listener and C# server mode, TS and Python server mode (Rust server mode resets on every Connect instead) | Cut 1c | | G1b | C# and Python server-mode transports drop a Connect from any endpoint but the first, so a client that restarts on a new port is locked out for good | C#, Python | Cut 1c | -| P2 | An expired reliable packet leaves a permanent hole. Ordered frames after it are held forever, and `flush_reliable` succeeds (outstanding 0) | Rust (the only runtime with reliable expiry) | Cut 4 reports it honestly; **Q-A3** decides the real fix | +| P2 | An expired reliable packet leaves a permanent hole. Ordered frames after it are held forever, and `flush_reliable` succeeds (outstanding 0) | Rust (the only runtime with reliable expiry) | Cut F (FORWARD-TSN, Q-A3 B); Cut 4 reports it honestly where a receiver never acknowledges the notice | | P9 | A receipt for an expiring send reads `Acknowledged` once the packet expires unacknowledged | Rust | Cut 4 | | P10 | `send_reliable` with a full window queues the frame, then returns an error ("receipts require a non-empty reliable packet set"), so a caller that retries sends it twice | Rust | Cut 4 | +And two faults found by Soul passes during pass 3: + +| # | Fault | Where | Fixed by | +|---|---|---|---| +| D1 | One peer whose datagrams cannot be sent fails every poll for every peer: the first failed `send_to` aborts `maintain` and `poll_once` (Odin Soul pass) | cultmesh-rs document server, Rust hub, C# listener; the cultmesh-py server's thread dies | Cut D (§10) | +| F2 | After Cut 2, a sender with reliable expiry pins one reliable fragment set per lossy frame, and the 65th refuses the session (Cut 2 Soul pass, default Rust `media`) | Rust, TS, Python receivers with Cut 2 | Cut F releases the sets; Cut 2 merges with it; Q-A9 for senders without notices | + ## Probe evidence @@ -170,6 +191,23 @@ Pass 2 (base `3bf1c0c`; mirror `CultLib-imag-ack2`): | G3 | Client sends one ordered write that is lost; the session ends; the client connects to a new server; the client's resends run | n/a | Owed after the end, and **delivered in the next session**: C#, TS, Python on peer Disconnect, local disconnect and timeout; Rust on local disconnect and timeout. Rust on peer Disconnect: owed 0, nothing delivered (`5f1e883d`) | Not probed | | G3R | Rust socket: `send_reliable`, the write is lost, `disconnect()`, `connect()` on the same transport to a new peer | n/a | Receipt `Pending` after the disconnect; the write is delivered to the new peer; **receipt `Acknowledged`** (the Soul R1 shape) | Not probed | +Pass 3 (base `e9469cb3`, transport files identical to `3bf1c0c`; mirror `probe3`, commits `3e2c7efb` for FN +and `c6f1012c` for CAP): + +- `packages/cultnet-rs/tests/probe_fn.rs`, `packages/cultnet-ts/test/probe-fn.test.ts`, + `packages/cultnet-py/tests/probe_fn.py`, `tests/GameCult.Networking.Tests/ProbeFnTests.cs` and + `packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/ProbeFn.kt`. +- Images: `eureka-verify-rust`, `mcr.microsoft.com/dotnet/sdk:10.0`, `node:24`, `python:3.12`, and for + Kotlin `eclipse-temurin:21-jdk` with `kotlin-compiler-2.2.21.zip` fetched in the job (the recipe Cut K0 + bakes into an image). + +| # | Probe | Result at `e9469cb3`, all five runtimes | +|---|---|---| +| FN | Sender `a` sends ordered `o1`, `o2`, `o3`; `o2` is lost, so the receiver `b` holds `o3`. `b` sends reliable `x`, which `a` receives. `a` then sends the Cut F notice exactly as specified: `create_ack()` encoded, flag byte `\|= 0x10`, sequence field = `o2`'s sequence. `b` decodes and receives it; then `o2` arrives late | **Old receivers ignore the notice and apply its acks.** It decodes as an Ack; `b`'s pending `[501]` (`x`) empties, so the header's acks were applied; nothing is delivered; `reply` is empty; `b` stays connected; `o2` is **not** marked received (not in `b`'s ack or mask). The late `o2` then delivers `[o2, o3]`: the hole was untouched. Identical in Rust, C#, TypeScript, Python and Kotlin | +| FN-T | Transport path (code, not run) | Every socket transport acks only a reliable, Accept or Data packet, or (TS, Python, Kotlin) one whose receive delivered a frame (`rudp.rs:2152-2155`, `:1713-1715`, `CultNetTransport.cs:2281-2283`, `:2787-2788`, `rudp.ts:1089-1091`, `transport.py:1086-1088`, K:3243-3245). An old receiver delivers nothing on a notice (FN), so the notice draws no reply datagram and is not counted dropped. Every transport already forwards a session's `reply` (`rudp.rs:2135-2137`, `:1708`, `CultNetTransport.cs:2277-2278`, `:2748-2749`, `rudp.ts:1067-1068`, `transport.py:1070-1071`, K:3229, `rudp_document_server.rs:334-336`, `cultmesh-ts/src/index.ts:5828-5830`, `operation-service.ts:186`, `cultmesh-py/.../server.py:246-247`), which is how a new receiver answers a notice | +| CAP | Q-A9 B's capability bit on old peers: `a`'s Connect and `b`'s Accept are each encoded, given flag bit 4, and decoded before delivery; then `a` sends one ordered frame (`probe_cap.rs`, `probe-cap.test.ts`, `probe_cap.py`, `ProbeCapTests.cs`, `ProbeCap.kt`) | **Old peers ignore the bit on the handshake.** Both sides connect, the frame is delivered, and the Connect is acknowledged (the sender's pending holds only the data frame). Identical in all five runtimes | +| K-img | Kotlin self-tests on Linux | `kotlinc-jvm 2.2.21` on OpenJDK 21.0.12 compiles `CultMesh.kt` plus a probe file, and `java -cp out.jar:kotlin-stdlib.jar org.gamecult.cultmesh.CultMeshKt` (the self-tests, K:3349-3373) exits 0 | + **Suites on the prototype `db7d2886`** (pass 1; baseline `03eac6ff` was all green): - `cultnet-rs`: 110 + 4 + 24 pass; `tests/cultnet.rs` 51 pass and 2 fail. Both failures are test shape, @@ -190,11 +228,13 @@ Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, (`CultNetTransport.cs` `Enum.IsDefined`, `rudp.rs` `packet_type_from_code`, `rudp.ts:1313`, `transport.py:1212`, `CultMesh.kt` `fromCode`) and an unknown version byte. So: -- An additive watermark would fit in an Ack: flag bit 4 set, the value in the Ack's unused sequence field, - zero extra bytes. Old peers apply the ack and ignore the rest. Q-A1 ruled it out; it stays the known - place to put a FORWARD-TSN notice if Q-A3 is ever B. +- An additive field fits in an Ack: flag bit 4 set, the value in the Ack's unused sequence field, zero + extra bytes. Old peers apply the ack and ignore the rest. Q-A1 ruled out a watermark there; Cut F puts + the FORWARD-TSN notice there (Q-A3 B), and pass 3's FN probe confirms the old-peer behaviour in all five + runtimes. - A new packet type or a version bump is not additive: old peers refuse the datagram and count it dropped. -- No capability negotiation is needed under this map, because nothing on the wire changes. +- Cut F's notice needs no negotiation to be safe: it reads as a plain ack to every current peer. Q-A9 asks + whether to advertise support anyway, for the receiver's sake (§6). ## Authority map @@ -209,8 +249,12 @@ Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, - It advances in `RememberReceived` and resets with the rest of the peer state (`ResetPeerState`). - **Owner of retransmission (sender): the pending and queued sets**, as today. A sequence leaves them when an ack names it. Retransmission stops there. This is not a public meaning. +- **Owner of abandonment (sender, Cut F): the abandoned set.** An expired sequence is announced by notices + until an ack names it or 16 sends pass; then it is stranded (`first_stranded`). The receiver's `Abandon` + branch is the only path that marks a sequence received without its packet, and it drops every incomplete + fragment set the sequence falls in. - **Owner of Acknowledged (sender): the acknowledged-through watermark `A`**, one below the lowest sequence - that is pending, queued or abandoned in the current generation. An ordered send is Acknowledged when `A` + that is pending, queued, awaiting its abandonment notice's ack, or stranded in the current generation. An ordered send is Acknowledged when `A` reaches its last sequence. A reliable unordered send is Acknowledged when every one of its sequences is acked. - **Owner of the session boundary: the session's generation.** One primitive ends a generation: the @@ -246,12 +290,16 @@ Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, - No path delivers an ordered frame except the drain after `R` advances. - No receive path drops a frame whose sequence it acknowledged while the generation lives. - No sender path reports Acknowledged except through `A` (or the unordered rule). - - No ending path leaves a write owed. No path retransmits a write into a generation after the one it was - issued in. + - No ending path leaves a write owed. No path retransmits a write, or sends a notice, into a generation + after the one it was issued in. + - No path marks a sequence received without a packet, except the `Abandon` branch (Cut F). + - No per-peer send failure ends a poll, a loop, a thread or another peer's session (Cut D). - No transport decides whether a Connect repeats: not by payload (Rust hub `rudp.rs:1637-1647`, C# listener `CultNetTransport.cs:2670-2688`), not by `Connected` (C# `:2226-2236`, Python - `transport.py:1047-1053`, TS `rudp.ts:249-251`, `:1031-1049`), and not by resetting on every Connect - (Rust server mode `rudp.rs:2105-2116`). + `transport.py:1047-1053`, TS `rudp.ts:249-251`, `:1031-1049`), not by server key or payload + (cultmesh-rs document server `rudp_document_server.rs:413-427`, cultmesh-ts document server + `index.ts:5794-5802`), and not by resetting on every Connect (Rust server mode `rudp.rs:2105-2116`, + cultmesh-py server `server.py:225-240`). - **Shared paths.** Accept, reliable data, fragments, duplicates and retransmits all advance `R` through `RememberReceived` and then call the drain. Direct sends, fragmented sends, queued promotion and resends all feed the pending and queued sets that `A` reads. Every ending path goes through the one generation @@ -303,7 +351,7 @@ Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, ### 3. The public API (Q-A2, ruled: one meaning) -- **Rust receipts** (the only runtime that has them): +- **Rust receipts** (the only runtime that has them today; §9 ports them to the other four): - `CultNetRudpReliableSendStatus` keeps its three variants, `Pending`, `Acknowledged`, `Invalidated` (`rudp.rs:146-150`). No variant is added, removed or renamed. `Acknowledged` changes meaning from "every packet was acked" to "delivered". @@ -344,15 +392,18 @@ Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, `transport.py:997`, `:1054`). Once Cut 1b drops owed writes at every ending, "outstanding is 0" after a reconnect would read as success, so every runtime binds flush to the session's generation in Cut 1b. - When every sent sequence is acked, `A` is the highest sequence sent, so every send is Acknowledged. - The only exception is behind an abandoned sequence (Rust expiry). There `flush_reliable` - (`rudp.rs:1992-2040`) fails at once when an ordered send follows the generation's first abandoned - sequence: that send can never be Acknowledged, and today flush reports success (P2). + Under Cut F an abandoned sequence is resolved by its notice's ack, so flush waits for that too. The + only exception is a stranded sequence (a notice the receiver never acknowledged). There + `flush_reliable` (`rudp.rs:1992-2040`) fails at once when an ordered send follows it: that send can + never be Acknowledged, and today flush reports success (P2). - C# `FlushReliable`, TS `flush`, Python `flush_reliable` and Kotlin `flushReliable` (`CultMesh.kt:3186`) keep their success condition. Their contract text becomes "every reliable frame - sent in this session has been delivered to the peer's application". These runtimes have no expiry. + sent in this session has been delivered to the peer's application". These runtimes have no expiry + unless Q-A8 gives them some (Cut 7). - **Defaults.** Plain flush. No parameter. No second flush. - **Compatibility.** No signature changes in any runtime except additions (`wait_acknowledged`; TS - `connectAndWait`, §4). The Rust enum is unchanged. No downstream caller has to change code at its next + `connectAndWait`, §4; the receipts of §9, where C# and Kotlin `SendSchema*` return a receipt instead of + nothing, which is source-compatible). The Rust enum is unchanged. No downstream caller has to change code at its next bump. ### 4. Consumers, each assigned @@ -366,7 +417,7 @@ consumer that waits wants delivered, which is now just Acknowledged. | `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) | | `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it | | `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | -| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` calls `createIdunnRuntimePresencePublisher` from its vendored CultLib `30ee8b9`, where the file is byte-identical to `3bf1c0c` | 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/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 | @@ -405,21 +456,140 @@ consumer that waits wants delivered, which is now just Acknowledged. - **Old senders** (the pinned and vendored ones) can still trip the Rust cap until they bump. - **Not in scope.** C#, TS, Python and Kotlin receivers have no hold cap. Follow-up. -### 6. Reliable expiry (P2) +### 6. Reliable expiry and FORWARD-TSN (P2; Q-A3 B, ruled) -- Only Rust has it (`rudp.rs:39`, `:898-908`). An expired packet's sequence never reaches the receiver. - `R` stops below it, and so did the old `next` pointer. Ordered frames after it are held for the rest of - the session. -- **Today's users.** Only Muninn's reliable `audio`, and its session carries no ordered traffic. - `hands/media-fec-cut3` (unmerged, `4b85a173`) deletes the `audio` profile entry and the `"audio"` send - arm. The `media` channel keeps a caller-chosen reliable-with-expiry mode. -- **This map makes it honest (Cut 4):** - - the sender records its generation's first abandoned sequence; - - `A` never passes it; - - ordered receipts after it stay `Pending`, and become `Invalidated` when the generation ends; - - `flush_reliable` fails at once. -- **The real fix is a fork (Q-A3).** The literature's answer is PR-SCTP's FORWARD-TSN (RFC 3758): the - sender tells the receiver to move its cumulative point past abandoned sequences. +- **The hole.** Only Rust has reliable expiry (`rudp.rs:39`, `:898-908`; the Python README's claim of + expiry at `packages/cultnet-py/README.md:103-111` is false, `transport.py` has none). An expired packet's + sequence never reaches the receiver, `R` stops below it, and every ordered frame after it on the session + is held until the session ends. +- **Who expires.** Every Rust `media` sender that does not opt into lossy delivery: the profile's media + delivery defaults to Reliable (`channel_delivery`, `rudp.rs:2515-2521`) and its expiry to 75 ms + (`rudp.rs:39`, set on the client and hub transports at `:1303` and through the profile at `:1548-1549`). + Media frames fragment. Muninn's reliable `audio` also expires; `hands/media-fec-cut3` deletes that profile + entry and moves Muninn's media to lossy delivery, but the default stays reliable-with-expiry for every + other caller. + +**What RFC 3758 does, and what this map keeps.** + +- The sender treats an abandoned chunk as "finally acked and no longer outstanding" (§3.5 A2) and advances + an `Advanced.Peer.Ack.Point` over consecutive abandoned TSNs. When that point passes the peer's + cumulative ack it sends FORWARD TSN (C1-C3), and tries again whenever T3-rtx expires (A5). +- FORWARD TSN carries a **New Cumulative TSN** plus (stream, stream sequence) pairs. The receiver "MUST + consider any missing TSNs earlier than or equal to this value as received" (§3.2), advances its + cumulative point, releases the listed streams' reordering queues, and acks "as if a DATA chunk had been + received" (§3.6). A FORWARD TSN at or behind its cumulative point is out of date, changes nothing, and + still draws a SACK. +- PR-SCTP is negotiated: an endpoint that did not see Forward-TSN-Supported in the peer's INIT must never + send FORWARD TSN (§3.1, §3.3). + +**How CultNet adapts it.** + +- **The notice names one sequence, not a cumulative point.** SCTP's SACK is cumulative, so a cumulative + FORWARD TSN is confirmed by the next SACK. CultNet's ack is not: it names the highest received sequence + plus a 32-bit mask, or one exact older sequence (`create_ack_for_received`, `rudp.rs:751-758`). A + cumulative notice could not be confirmed by that ack, which would mean a second wire field. So each + abandoned sequence gets its own notice, and the receiver answers it with the ack a reliable data packet + with that sequence would draw. That ack removes the sequence from the sender's abandoned set exactly as it + removes a data packet from pending. **Q-A7** puts the cumulative alternative to the operator. +- **No stream pairs.** One watermark `R` owns ordered delivery for every channel (Cut 1), so advancing `R` + and draining releases every channel's held frames. There is nothing per channel to name. +- **Negotiation: only if Q-A9 is B.** The ruling puts the notice in an Ack so an old peer reads it as an + ack (FN), and bounds the resend: an old receiver never acks the notice, and the sender stops after a + fixed number of sends. The Cut 2 Soul finding adds a reason to negotiate after all (Q-A9): a *receiver* + needs to know whether its peer will ever send notices. If Q-A9 is B, the sender also sends notices only + to a peer that advertised the bit (RFC 3758 §3.3), and an old receiver's sequence is stranded at once + instead of after the bound. The abandoned sequence is then *stranded*: the Cut 4 honest + report applies to it (ordered sends after it stay Pending, become Invalidated at the end, and flush fails + fast). That is the behaviour ruling A would have given everywhere; under B only old receivers get it. + +**Wire.** Packet type `4` (ack), flag bit 4 (`0x10`, "abandon"), and no other flag bit. Sequence field: the +abandoned sequence. Ack and ack mask: the sender's current ack state, so the notice is also a genuine ack. +Channel `control`, fragment fields `0`, empty payload. Decoders map type 4 with bit 4 to a new packet type, +**`Abandon`** (Rust `CultNetRudpPacketType::Abandon`, C# `CultNetRudpPacketType.Abandon`, TS `"abandon"`, +Python `CultNetRudpPacketType.ABANDON`, Kotlin `CultNetRudpPacketType.Abandon`), and the encoder maps it +back. Bit 4 on any other type is ignored, as today. No public packet struct gains a field, and no +downstream repo constructs packets or matches the type exhaustively (surveyed: Muninn tests compare +`== Data`; Mimir `CultMeshMedia` compares `Connect` and `Accept`). + +**Golden** (added beside the existing fixture in every runtime and in `docs/cultnet-transport-parity.md` +after the canonical fixture, `:505-517`): + +```text +packetType=abandon (wire: type 4, flags 0x10) +connectionId=0x01020304 +sequence=0x0000002a +ack=0x00000029 +ackMask=0x80000001 +channelId=control +payload=(empty) +hex=434e52300004102b010203040000002a0000002980000001000000000000000000000700636f6e74726f6c +``` + +**Sender (Rust now; any runtime that gains expiry under Q-A8).** + +- An expired packet, pending or queued, leaves the pending and queued sets as today and enters an + **abandoned set**: sequence, sends so far, last sent. It is due at once. +- `due_resends` emits a fresh notice for each due entry (built from the current ack state, not stored), + once per resend delay. After `RUDP_ABANDON_NOTICE_SENDS = 16` sends without an ack the entry leaves the set + and the session records the lowest such sequence as **`first_stranded`**. Sixteen sends at the default + 250 ms is four seconds. At 50% loss in each direction a round trip fails with probability 3/4, and all + sixteen with about 1%; at 20% each way, about 10^-7. The bound is for deaf receivers, not bad links, and + a new receiver that misses every notice degrades to the honest report, never to a lie. +- Any ack naming the sequence removes it from the abandoned set. That ack means the receiver holds the + sequence, from the original packet arriving late or from the notice; either way it is no longer a hole. +- Notices take no send-window slot and no payload bytes. For Cut 3's span rule, an abandoned sequence with + an outstanding notice counts as unacked (the receiver's `R` is held below it); a stranded one does not + (an old receiver has no `R` to hold, and counting it would stop the session after 1,023 more sequences). +- `outstanding_reliable_packet_count` counts abandoned entries, so a flush waits for their notices. +- The session-ending primitive (Cut 1b) and `reset_peer_state` clear the abandoned set and + `first_stranded`: they belong to the generation. + +**Receiver (all five runtimes).** + +- `receive` handles `Abandon` after applying its acks, as RFC 3758 §3.6 does: + - outside `[highest − 4,095, highest + 1,024]`: far-ahead is ignored without remembering (as Rust data + is, `rudp.rs:626-641`), far-behind is a duplicate; + - every incomplete fragment set whose sequence span contains the sequence is dropped (a set's span is + `sequence − fragment_index` for any fragment it holds, for `fragment_count` sequences), whether or not + the sequence was received: that frame can never complete; + - if the sequence was not received, it is remembered through `RememberReceived`, which advances `R` + (Cut 1), and held frames up to `R + 1` drain into `delivered`; + - `reply` is `create_ack_for_received(sequence)`, whether or not anything changed (the out-of-date case + still acks). +- Transports send the reply as they already do, and skip their own ack for an `Abandon` packet + (`rudp.ts:1089`, `transport.py:1087`, K:3243 would otherwise ack it a second time when the drain + delivered). + +**Interaction with the other cuts.** + +- **Cut 1 (`R`).** The notice is the only receive path besides data and Accept that calls + `RememberReceived`; it shares the drain. A late original of an abandoned packet is then a duplicate: acked + and dropped, as PR-SCTP drops it. +- **Cut 1b (ending).** An abandoned entry is a write of its generation; the ending primitive drops it. No + notice is ever sent into a later generation. +- **Cut 1c (generations).** A stale notice from an earlier generation of the same sender carries a + sequence below the new generation's Connect (issued sequences survive resets), so it is a duplicate. A + stale notice from a dead process on the same endpoint and connection id could mark a sequence of the new + process's session, exactly as a stale data packet can be delivered into it today; the random initial + sequence makes both land inside the 5,120-sequence acceptance range with probability about 2^-18. +- **Cut 2 (fragment eviction), from the Cut 2 Soul pass.** Cut 2 never evicts a reliable fragment set and + refuses the session when every pending set is reliable. Its claim that a conforming sender cannot reach + the 64-set bound is **false for an expiring sender**: fragment 0 arrives and is acked, the deadline drops + the rest, and the set is pinned for the session. Soul reproduced the refusal on the 65th lossy default + `media` frame (`soul_probe_expiring_reliable_frames_kill_receiver`, `packages/cultnet-rs/tests/cultnet.rs:3192` + at probe `3be97b34`). Before Cut 2, eviction kept that session alive. + - **With a Cut F sender** the notice for each dropped fragment lands inside the set's span and the + receiver drops the set: the rule above, explicitly "every incomplete set whose span covers a notified + sequence". Dead sets then live one round trip, not the session. + - **With a sender that never sends notices** (every build before Cut F) the receiver cannot tell an + expiring set from a set whose missing fragment is still coming. **Q-A9** decides what the receiver does + then; its recommendation is PR-SCTP's own answer, a capability bit on Connect and Accept (RFC 3758 + §3.1, Forward-TSN-Supported): Cut 2's never-evict rule applies to a peer that advertised it, and the + pre-Cut-2 stalest-set eviction stays for a peer that did not. + - So Cut 2 does not merge alone: it merges together with Cut F, which carries the capability and the + gate (§ Sequencing). +- **Cut 4.** Reads `first_stranded` where pass 2 read `first_abandoned`, and counts outstanding notices as + not yet Acknowledged. ### 7. Interaction with the merged stray-packet work @@ -453,7 +623,7 @@ The ack cuts build on these behaviours and do not alter them: - **What must hold after both.** A write is Acknowledged or Invalidated within the generation it was issued in. A restarted client is admitted. A new server session's frames are delivered after a reconnect. A retransmitted Connect is still a repeat. -- **Recognising a new generation (Cut 1c, Q-A6 recommendation A).** The Connect's sequence identifies the +- **Recognising a new generation (Cut 1c, Q-A6 A, ruled).** The Connect's sequence identifies the generation, and the initial sequence is random by default, as TCP's initial sequence number is (RFC 9293 §3.4.1, RFC 6528). - A Connect repeats exactly when it carries the sequence of the Connect that started the current @@ -473,17 +643,99 @@ The ack cuts build on these behaviours and do not alter them: `cultnet-rs/src/provider_session.rs:70-79`), carried to the application as today. It no longer decides anything in the transport. +### 9. Receipts in C#, TypeScript, Python and Kotlin (Q-A4 A, ruled) + +- **One design, ported.** Cut 4 builds it in Rust: a receipt carries the sequences its send assigned, + whether it was ordered, and a status cell the session writes once. The session keeps the unresolved + receipts and resolves them after every ack it applies (Acknowledged: ordered when `A` reaches its last + sequence, unordered when every sequence is acked) and in the ending primitive (Invalidated). The four + ports copy that shape; none invents a second one. +- **What every runtime has to change first** (the receipts survey, `e9469cb3`): + - `SendMany`/`sendMany`/`send_many` returns only the packets admitted to the 32-packet window + (`CultNetTransport.cs:1439-1465`, `rudp.ts:586-592`, `transport.py:776-786`, K:2856-2862). A receipt + must be built from every sequence the send *assigned*, queued ones included, which is Rust's P10. + - `ApplyAcknowledgements` only deletes from the pending map (`CultNetTransport.cs:1503-1516`, + `rudp.ts:610-617`, `transport.py:803-807`, K:2882-2889). Resolution hooks in after it. + - Invalidation needs the ending primitive. C#, TS and Python get it in Cut 1b; Kotlin in K2. +- **Where the receipt lives.** On the single-peer socket transport connection, as in Rust + (`CultNetRudpSocketTransportConnection::send_reliable`, `rudp.rs:1903-1930`). The Rust hub has no + receipts, so neither do the C# listener (`CultNetRudpSocketTransportServer`, `:2487`), `cultmesh-ts`'s + document server or `cultmesh-py`'s server. Hub receipts are a follow-up for all five together. +- **Public API, per runtime.** Every runtime's RUDP surface is synchronous-with-timeout except TypeScript's, + which is Promise-and-event. Each port matches its own runtime: + +| | Send | Receipt | Status | Wait | +|---|---|---|---|---| +| Rust (Cut 4) | `send_reliable(&mut self, channel, payload) -> Result` | `packet_sequences()` | `reliable_send_status(&receipt) -> CultNetRudpReliableSendStatus` | `wait_acknowledged(&receipt, timeout) -> Result<()>` | +| C# (6a) | `CultNetRudpSendReceipt SendReliable(string channelId, byte[] payload)`; `SendSchema(...)` and `SendSchemaMessage(...)` return the receipt instead of `void` (source-compatible) | sealed class, `IReadOnlyList PacketSequences` | `receipt.Status` (`CultNetRudpSendStatus { Pending, Acknowledged, Invalidated }`), read without the transport | `void WaitAcknowledged(CultNetRudpSendReceipt receipt, TimeSpan? timeout = null)`, the `FlushReliable` loop (`:1914-1955`); throws `InvalidOperationException` naming the end reason when Invalidated, `TimeoutException` at the deadline | +| TypeScript (6b) | `sendReliable(channelId: string, payload: Uint8Array): CultNetRudpSendReceipt`. `send` keeps `void`, because the transport interface declares it (`transport.ts:132-142`) | `readonly packetSequences: readonly number[]` | `receipt.status: "pending" \| "acknowledged" \| "invalidated"` (string union, as packet types are) | `waitAcknowledged(receipt, timeoutMs = 30_000): Promise`, polling like `flush` (`rudp.ts:911-935`); rejects with the end reason when invalidated | +| Python (6c) | `send_reliable(self, channel_id: str, payload: bytes) -> CultNetRudpSendReceipt` | frozen-looking object, `packet_sequences: tuple[int, ...]` | `receipt.status` (`CultNetRudpSendStatus` enum: `PENDING`, `ACKNOWLEDGED`, `INVALIDATED`) | `wait_acknowledged(self, receipt, timeout_seconds: float = 30.0) -> None`, the `flush_reliable` loop (`:1101-1131`); raises `ConnectionError` when invalidated, `TimeoutError` at the deadline | +| Kotlin (K4) | `fun sendReliable(channelId: String, payload: ByteArray): CultNetRudpSendReceipt`; `sendSchema`/`sendSchemaMessage` return it | `val packetSequences: List` | `receipt.status` (`enum class CultNetRudpSendStatus { Pending, Acknowledged, Invalidated }`), `@Volatile` | `fun waitAcknowledged(receipt, timeoutMs: Long = 30_000, pollIntervalMs: Long = 5)`, the `flushReliable` loop (K:3186-3205); throws `IOException` when invalidated, `SocketTimeoutException` at the deadline | + + - The status is on the receipt, not a transport query, in the four ports: a C#, TS, Python or Kotlin + caller reading `receipt.Status` is the idiom there, and it is what Rust's cell already is underneath. + Rust keeps `reliable_send_status` because its signature is public and used (Cut 4, Unchanged). + - `SendReliable` refuses a channel whose send options are not reliable (as Rust's `send_reliable` does, + `rudp.rs:1903-1930`). If Q-A8 brings expiry to a runtime, it refuses an expiring channel too, as Rust's + does after Cut 4 (P9). + - A receipt from an ended session is Invalidated and stays so; `WaitAcknowledged` on it fails at once. +- **Thread safety.** C# resolves under `_pendingReliableGate` (`:915`) and publishes the status with a + volatile write; Kotlin marks the field `@Volatile`; TS and Python are single-threaded per transport. +- **Docs.** Each runtime's README RUDP section gains the three states and the wait + (`src/GameCult.Networking/README.md`, `packages/cultnet-ts/README.md:24-25`, + `packages/cultnet-py/README.md:103-111`, `packages/cultmesh-kotlin/README.md:229-305`), and the parity + doc's flush paragraph (`docs/cultnet-transport-parity.md:260-273`) names receipts in all five. + +### 10. A failed send to one peer is that peer's lost datagram (Cut D; from the Odin Soul pass) + +- **Finding.** `CultMeshRudpDocumentServer::poll_once` begins with `self.maintain()?` + (`cultmesh-rs/src/rudp_document_server.rs:264`). `maintain` sends every peer's due resends and returns on + the first failed `send_to` (`:392-398`, through `send_packet` `:628-632`). Every per-peer send in + `poll_once` does the same: the reply (`:334-336`), the Accept (`:451`), a snapshot response (`:586-588`), + the goodbye (`:597-604`) and the ack (`:366-373`). + - So one peer whose datagrams cannot be sent fails every poll for every peer. Its resends come due every + 50 ms (`:158`); a poll loop that pauses longer than that between attempts never gets past `maintain` + again while the peer lives, and a peer that keeps sending never idles out (`:384-388`). + - Odin `origin/main` (`crates/odin-daemon/src/main.rs:661-675`) propagates any `poll_once` error, so + today the daemon exits on the first one; the Odin Soul pass reports a branch that retries every 100 ms + and exits after 30 s of failures. Either way one bad peer takes Odin down. + - The same socket is **non-blocking** (`:230`), so `send_to` also fails with `WouldBlock` when the send + buffer is full: a burst of snapshot responses to one peer can fail a poll for everyone with no bad peer + at all. +- **Rule: a send failure is a lost datagram, attributed to its peer.** It is counted and the poll goes on. + RUDP already recovers lost datagrams: the reliable packet stays pending and is resent; an unreliable one + was allowed to be lost. The peer's session ends by the rules that already end sessions: idle timeout, + maximum lifetime, a refusal or a Disconnect. This is TCP's treatment of ICMP unreachables as soft errors + (RFC 1122 §4.2.3.9): report them, do not abort the connection on them. + - Only failures that are not about one peer stay errors: `recv_from` failing with anything but + `WouldBlock` or `ConnectionReset` (Windows reports an earlier ICMP port-unreachable on the *next* + receive; the socket transport already treats it as nothing, `rudp.rs:2059-2066`, the document server does + not, `:266-272`), and encode failures, which are the server's own bug. + - Rejected: ending the peer's session on its first failed send. `WouldBlock` is transient and not the + peer's fault; ending on it would drop healthy peers under load. +- **Same shape elsewhere** (multi-peer servers only; a single-peer transport's failed send is already only + that peer's): + +| Server | Where one peer's failed send stops the others | Change | +|---|---|---| +| cultmesh-rs document server | as above | Cut D | +| Rust hub `CultNetRudpServerHub` | `poll_resends` returns on the first failure (`rudp.rs:1762-1777`); the receive path's sends to one peer abort `receive_event_once` (`:1644`, `:1673`, `:1708`, `:1711`, `:1714`, `:1759`, through `send_packet` `:1799-1804`) | Cut D | +| C# `CultNetRudpSocketTransportServer` (and `RudpCultNetSchemaServer` over it) | `PollResends` walks every peer and the first `SocketException` from `SendTo` (`CultNetTransport.cs:2829-2834`) escapes the loop (`:2796-2805`); `TryReceiveOnce`'s sends are unguarded except the refusal goodbye (`:2687`, `:2704`, `:2749-2750`, `:2788`; guarded `:2739-2745`) | Cut D | +| cultmesh-py server | worse: an `OSError` from `sendto` (`server.py:302-307`) in `_poll_rudp_resends` (`:277-285`) or the receive path escapes `_rudp_loop` (`:203-…`, which catches only `recvfrom`'s errors), and the RUDP thread dies for every peer | Cut D | +| cultmesh-ts document server | none: Node's `socket.send` is asynchronous and reports failures on the socket's `error` event (`cultmesh-ts/src/index.ts:5858`), which `reportError` logs (`:5778-5785`) | none | +| Kotlin interop server | none that matters: a catch-all logs and continues (K:4025), and it is interop-only | none (Cut K1 reviews it) | + ## Cuts -Every cut is on CultLib, branch `hands/rudp-delivered` from `main` at `3bf1c0c` or later, in a worktree -Self creates. Heavy verification runs on Yggdrasil. Each cut starts by committing its probes as failing +Every cut is on CultLib, on its own branch (`hands/ack-cut`) from `main` at `e9469cb3` or later, in a +worktree Self creates. Heavy verification runs on Yggdrasil. Each cut starts by committing its probes as failing tests named for the rule, in every runtime it touches. The pass-1 and pass-2 probe files (`refs/verify/1519a20a` on the `CultLib-imag-ack2` mirror) are the starting point; they print, so each must become an asserting test. -Kotlin: every cut below is mapped for C#, Rust, TS and Python. If Q-A5 is A, a trailing Cut K applies -Cuts 1, 1b, 1c and 3 to `CultMesh.kt` at the anchors given, on the verification host Q-A5 picks. +Kotlin: every numbered cut below is mapped for C#, Rust, TS and Python. Cut K (Q-A5 A) brings Kotlin to +the same state on a Yggdrasil Kotlin image, in steps K0-K5. **Negative checks that stay green in every cut:** @@ -491,9 +743,9 @@ Cuts 1, 1b, 1c and 3 to `CultMesh.kt` at the anchors given, on the verification (`docs/cultnet-transport-parity.md:227-248`); - the stray-packet tests (`packages/cultnet-rs/tests/rudp_stray_packets.rs`, and their C#, TS and Python equivalents), except the ones each cut names as encoding the old rule; -- the codec fixture vectors: no wire byte changes. +- the codec fixture vectors: the existing fixture's bytes never change. Cut F adds one fixture. -### Cut 1. One watermark owns ordered delivery (receiver) — ready +### Cut 1. One watermark owns ordered delivery (receiver) — in flight (`hands/ack-cut1`) - **First.** Failing tests P1, P1b and P6 in each runtime. - **Deletes first:** @@ -529,7 +781,7 @@ Cuts 1, 1b, 1c and 3 to `CultMesh.kt` at the anchors given, on the verification - mutation testing on the diff: cargo-mutants, Stryker.NET, StrykerJS, and mutmut for Python. - **Ledger.** Per runtime about −70 and +40. Across four, about −280 and +160. -### Cut 1b. A write belongs to the session it was issued in: every ending drops what it owed — ready +### Cut 1b. A write belongs to the session it was issued in: every ending drops what it owed — in flight (`hands/ack-cut1`) - **First.** Failing tests G3 (each ending path: peer Disconnect, local disconnect, timeout, refusal) in C#, TS and Python, and local disconnect and timeout in Rust; G3R in Rust; and in every runtime, a flush @@ -576,62 +828,120 @@ Cuts 1, 1b, 1c and 3 to `CultMesh.kt` at the anchors given, on the verification - mutation testing on the diff. - **Ledger.** About −40 and +45 across four runtimes. TS is net negative. -### Cut 1c. A Connect's sequence says whether it repeats — blocked on Q-A6 +### Cut 1c. A Connect's sequence says whether it repeats — ready after Cut 1b (Q-A6 A, ruled) -Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending primitive). +Pass 3 changes against pass 2: the ruling; three more servers that decide repeats themselves (the +cultmesh-rs and cultmesh-ts document servers and the cultmesh-py server); the second C# default of 100; +the exact default mechanism; the full caller survey; `cultmesh-rs/src/lib.rs:1409` removed from the +production list (it is inside `#[cfg(test)]`, which starts at `:1140`). -- **First.** Failing tests G1 (the hub, the C# listener, and server mode in every runtime: a restarted - client on the same address and payload is admitted and its frames delivered), G1b (C# and Python server - mode admit a Connect from a new endpoint), G2 (after `connect()` on the same session, a new server - session's frames are delivered), and a stale Accept (its ack field does not name the pending Connect) is - ignored. +- **Repo/branch:** CultLib `hands/ack-cut1c` from `main` after Cut 1b merges. It uses the ending primitive. +- **First.** Failing tests: + - G1: a restarted client on the same address, connection id and payload is admitted and its frames + delivered, against the Rust hub, the C# listener, server mode in every runtime, the cultmesh-rs + document server (same `(addr, connection_id)` key), the cultmesh-ts document server (same payload) and + the TS operation service; + - G1b: C# and Python server mode admit a Connect from a new endpoint; + - G2: after `connect()` on the same session, a new server session's frames are delivered; + - a stale Accept (its ack field does not name the pending Connect) is ignored; + - the cultmesh-py server and Rust server mode: a *retransmitted* Connect after data has flowed does not + reset the session (today both reset on every Connect). - **Deletes first:** - - the transport-level repeat decisions: Rust server mode's reset on every Connect - (`rudp.rs:2105-2116`); the Rust hub's payload comparison (`rudp.rs:1637-1647`); C# server mode's - `_session.Connected` branch (`CultNetTransport.cs:2226-2236`); the C# listener's payload comparison - (`CultNetTransport.cs:2670-2688`); TS's `movedEndpoint`/`repeated` computation (`rudp.ts:1011-1049`) - and the session's own `connected` shortcut (`rudp.ts:249-251`); Python's `connected` branch - (`transport.py:1047-1053`); + - the transport-level repeat decisions: Rust server mode's reset on every Connect (`rudp.rs:2105-2116`); + the Rust hub's payload comparison (`rudp.rs:1637-1647`); C# server mode's `_session.Connected` branch + (`CultNetTransport.cs:2226-2236`); the C# listener's payload comparison (`:2670-2688`); TS's + `movedEndpoint`/`repeated` computation (`rudp.ts:1011-1049`) and the session's `connected` shortcut + (`rudp.ts:249-251`); Python's `connected` branch (`transport.py:1047-1053`); + - **new in pass 3:** the cultmesh-rs document server's "same key is a repeat" arm + (`cultmesh-rs/src/rudp_document_server.rs:413-427`, and its comment at `:414-415` that asks a fresh + client to choose a fresh id); the cultmesh-ts document server's `connectPayload` comparison and field + (`cultmesh-ts/src/index.ts:5794-5802`, `:5806`); the cultmesh-py server's replace-on-every-Connect + (`cultmesh-py/src/cultmesh_py/server.py:225-240`); - C# and Python server mode's refusal of a Connect from a new endpoint (`CultNetTransport.cs:2212-2216`, - `transport.py:1042-1044`), for Connect packets only. Rust and TS already admit it. + `transport.py:1042-1044`), for Connect packets only. - **Adds:** - - the session records the sequence of the Connect that started its generation, and decides: a Connect - repeats exactly when it is connected and the Connect carries that sequence. `AcceptConnect` answers a - repeat as `AnswerRepeatedConnect` does today, and otherwise ends the generation, resets peer state and - accepts (`CultNetTransport.cs:1012-1059`, `rudp.rs:383-435`, `rudp.ts:242-290`, - `transport.py:485-525`). `AnswerRepeatedConnect` stops being public; a public predicate - (`ConnectRepeats(packet)`) serves the hubs, which keep building a fresh session for a new generation and - emit Disconnected for the old one (`rudp.rs:1648-1690`, `CultNetTransport.cs:2690-2710`); + - the session records the sequence of the Connect that started its generation. A Connect repeats exactly + when the session is connected and the Connect carries that sequence. `AcceptConnect` answers a repeat + as `AnswerRepeatedConnect` does today, and otherwise ends the generation, resets peer state and accepts + (`CultNetTransport.cs:1012-1059`, `rudp.rs:383-435`, `rudp.ts:242-290`, `transport.py:485-525`). + `AnswerRepeatedConnect` stops being public; a public predicate `ConnectRepeats(packet)` / + `connect_repeats` / `connectRepeats` serves servers that keep one session per peer and must report the + old one ended (the Rust hub `rudp.rs:1648-1690`, the C# listener `:2690-2710`, the cultmesh-rs document + server). Servers that only call `AcceptConnect` (TS operation service `operation-service.ts:167-175`, + cultmesh-ts document server, cultmesh-py server, server-mode transports) need nothing else: the session + decides; - `CreateConnect` on a session that has had a peer ends the generation and resets peer state first (`CultNetTransport.cs:1001-1010`, `rudp.rs:369-381`, `rudp.ts:229-240`, `transport.py:473-483`); - on Accept receipt, the session honours it only while its Connect is pending and only if the Accept's ack field names that Connect (`CultNetTransport.cs:1155-1160`, `rudp.rs:544-557`, `rudp.ts:379-390`, `transport.py:601-604`); - - the default initial sequence is random in [1, 2^31): Rust `CultNetRudpSessionOptions::default` - (`rudp.rs:104-113`), the transport constructors (`:1311`, `:1332`) and hub options (`:1377`); C# - `CultNetTransport.cs:773`, `:850`, `:2404`; TS `rudp.ts:172`; Python `transport.py:298`, `:323`; - - CultLib's own fixed `initial_sequence: 1` call sites take the default: `cultmesh-rs/src/lib.rs:964`, - `:1409`, `cultmesh-rs/src/rudp_document_server.rs:432`, `cultnet-rs/src/cultmesh.rs:259`, - `cultnet-ts/src/signed-daemon-health.ts:116`. -- **Tests changed** (they encode payload identity or a fixed initial sequence): + - **the default initial sequence is drawn from a CSPRNG in [1, 2^31)** wherever a default is produced. + The option types do not change (`u32`/`uint`/`number`/`int`), so every explicit value keeps working and + no caller changes code. One private helper per runtime: + - Rust: `rand::random_range(1..1u32 << 31)` (`rand` 0.9 is already a dependency, + `packages/cultnet-rs/Cargo.toml`), in `CultNetRudpSessionOptions::default` (`rudp.rs:108`), + `CultNetRudpSocketTransportOptions::client()` (`:1310`) and `::server()` (`:1329`), + `CultNetRudpServerHubOptions::new()` (`:1377`), and `CultMeshRudpSocketOptions::default` + (`cultnet-rs/src/cultmesh.rs:259`); + - C#: `RandomNumberGenerator.GetInt32(1, int.MaxValue)` as the property initializer at + `CultNetTransport.cs:773`, `:850`, **`:2404` (the listener's 100)**, **`CultNetRudpSchemaServer.cs:48` + (a second 100)** and `src/GameCult.Mesh/CultMesh.cs:547`; + - TypeScript: `crypto.getRandomValues` in the session constructor's `options.initialSequence ?? …` + (`rudp.ts:172`); `cultmesh-ts`'s optional `initialSequence` (`index.ts:4336`) already falls through to it; + - Python: `secrets.randbelow(2**31 - 1) + 1` as a `default_factory` at `transport.py:298`, `:323`, and + as the default of `client.py:396` and `cultmesh-py/src/cultmesh_py/facade.py:160`, `:194` (keyword + defaults become `None` meaning "draw one", since a Python default is evaluated once); + - Kotlin: in Cut K2; + - CultLib's own production pins take the default: `cultmesh-rs/src/lib.rs:964` (client), + `cultmesh-rs/src/rudp_document_server.rs:432` (server). The two TS publishers + (`signed-daemon-health.ts:116`, `idunn-odin-presence-publisher.ts:103`) lose theirs in whichever of + Cut 5 or Cut 1c lands second. Interop peers, the parity tool and tests keep explicit values on purpose. +- **Tests changed** (they encode payload identity or a fixed default): - `packages/cultnet-rs/tests/rudp_server_hub.rs:171-240` calls `connect()` again with the same payload - and expects no replacement. Under this rule that is a new generation. Its retransmit half is already - covered by `rudp_stray_packets.rs:487` (the same Connect packet twenty times), which stays; - - `tests/GameCult.Networking.Tests/NetworkingTests.cs:1507` replaces a peer by payload with two sessions - at the default initial sequence; it passes under a random default, but must say it relies on distinct - sequences; - - `packages/cultnet-ts/test/cultnet.test.ts:2225` (another endpoint replaces the peer) stays; - - any test that asserts a sequence value with the default initial sequence. + and expects no replacement; under this rule that is a new generation. Its retransmit half stays covered + by `rudp_stray_packets.rs:487`; + - `tests/GameCult.Networking.Tests/NetworkingTests.cs:1507` (replace a peer by payload) passes but must + say it relies on distinct sequences; `:700` asserts `PendingReliableSequences == {100}` against the + listener's default; + - asserts that may rely on a default Connect sequence of 1 (read each; pin `1` in the test if it does not + already): `cultnet-rs/tests/cultnet.rs:574`, `:820`, `:851-852`; `cultnet-ts/test/cultnet.test.ts:472`, + `:576`, `:592-593`, `:1536`; `cultnet-py/tests/test_cultnet.py:596`, `:764`, `:779-780`; + - `cultnet-ts/test/cultnet.test.ts:2225` (another endpoint replaces the peer) stays. - **Verification:** - - G1, G1b, G2 and the stale-Accept test in every runtime; + - G1 (every server above), G1b, G2, the stale Accept and the retransmitted-Connect tests; - the retransmit tests stay green: `rudp_stray_packets.rs:487`, `:511`, `NetworkingTests.cs:1483`, `cultnet.test.ts:2119`, `:2134`, `test_cultnet.py:1134`; - - negative grep: `rg -n "SequenceEqual\(connectPayload\)|connect_payload == packet.payload|movedEndpoint" src packages` + - a test per runtime that two default-constructed sessions draw different initial sequences in [1, 2^31); + - negative grep: `rg -n "SequenceEqual\(connectPayload\)|connect_payload == packet.payload|movedEndpoint|byteArraysEqual\(record.connectPayload" src packages` returns nothing; - mutation testing on the diff. -- **Ledger.** About −60 and +70 across four runtimes. Ten call sites lose an explicit initial sequence. +- **Ledger.** About −90 and +85 across four runtimes and the three servers. Two CultLib production pins go. -### Cut 2. A reliable fragment set is never evicted (receiver: Rust, TS, Python) — ready +**Pin-bump notes: callers outside CultLib that pin an initial sequence** (survey, 2026-09-30). A pinned +value keeps a deterministic Connect sequence. It matters on a **client that can restart on the same +endpoint and connection id** (the G1 lockout survives there); on a server or a one-shot client that binds a +fresh port per run it only forgoes the stale-packet protection. Recommendation at each repo's bump: delete +the value. + +| Repo@rev | Site | Value | Role | Consumes CultLib by | +|---|---|---|---|---| +| Muninn@`bd69598` | `crates/muninn-daemon/src/main.rs:5975`; `crates/sleipnir-daemon/src/main.rs:2223` | 1 | client (restart risk) | Cargo pin `c2a9a6e5` | +| Muninn@`bd69598` | `muninn-daemon/src/main.rs:780`, `:5426`; `sleipnir-daemon/src/main.rs:2265` | 1 | server-mode transports | same | +| Gjallar@`6a92976` | `src/Gjallar/Program.cs:431`, `:2955`, `:3027` | 1 | clients (restart risk) | sibling `../CultLib` | +| Mimir@`d88f2e6` | `src/Mimir.EveBrowserReference/Program.cs:682`, `src/Mimir.EveDashboard/Program.cs:801` | 1 | clients (restart risk) | sibling ProjectReference | +| Mimir@`d88f2e6` | `src/Mimir.CultMeshMedia/Program.cs:485` | `peers.Count + 1` | hand-rolled per-peer relay; its Connect handling (`:365`) is its own repeat decision | same | +| weksa@`d16cff5` | `scripts/weksa-rudp-command.cjs:88` (per-peer server loop), `:192` (client); `scripts/idunn-rudp-health.cjs:43` | 1 | server loop and clients | sibling CultLib | +| Bifrost@`42728a6` | `tools/persona-discord-rudp.mjs:9` | 100 | server-mode transport | sibling CultLib | +| Heimdall@`e8e2832` | `src/idunn-rudp-health.ts:65` | 1 | one-shot health client (replaced at its bump, § Sequencing) | submodule `b6b1d9c` | +| Stonks@`981c484`, Vili@`8773c15`, VoidBot@`46d891b` | `src/idunn-rudp.cjs:43`, `scripts/idunn-rudp.cjs:33`, `scripts/publish-idunn-rudp-health.cjs:64` | 1 | one-shot health clients | `file:../CultLib` / sibling path | + +- None in Odin `origin/main` (`5c37860`; Sleipnir now lives in Muninn), Idunn, Hermodr, CodexConnector, + Ghostlight, Huginn or Epiphany. StreamPixels' own code has none; its vendored CultLib (`542ddd3`, at + StreamPixels `a49f7d8`) carries CultLib's own old defaults and pins, which a bump replaces. +- Sibling-path consumers (Gjallar, Mimir, Stonks, Vili, VoidBot, weksa, Bifrost) take the random default + as soon as the local CultLib checkout moves; their pinned sites do not change behaviour until edited. + +### Cut 2. A reliable fragment set is never evicted (receiver: Rust, TS, Python) — in flight (`hands/ack-cut2`); merges with Cut F - **First.** Failing test P5 in each of the three. - **Change:** @@ -639,13 +949,41 @@ Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending pr `rudp.ts:679-692`, `transport.py:836-842`); - if every pending set holds reliable fragments, the packet is refused and the session ends, as every other refusal does; - - a conforming sender cannot reach that bound: its window keeps at most 32 reliable packets in flight, - and the bound is 64 sets. + - ~~a conforming sender cannot reach that bound~~ (false for an expiring sender; see the pass 3 + correction below): its window keeps at most 32 reliable packets in flight, and the bound is 64 sets. - **Verification:** P5 delivers the reliable frame. `d3bc6e66`'s test (lossy fragment sets keep being evicted) stays green. `fragment_sets_evicted` still counts evictions. - **Ledger.** About −3 and +12 per runtime. +- **Pass 3 correction (from the Cut 2 Soul pass on `hands/ack-cut2` `c5026aa1`).** The third bullet above + is false for an expiring sender: the default Rust `media` channel expires reliable packets after 75 ms, + a frame's first fragment is delivered and acked, the rest are dropped by the deadline, and the set is + pinned; Soul's `soul_probe_expiring_reliable_frames_kill_receiver` (`packages/cultnet-rs/tests/cultnet.rs:3192` + at `3be97b34`) refuses the session on the 65th such frame, where eviction used to keep it alive. + - **What changes for the branch in flight:** nothing in its code. Its rule is right for every sender that + does not expire. What changes is when it merges: **Cut 2 merges only together with Cut F**, which + releases dead sets through notices and (Q-A9 B) keeps pre-Cut-2 eviction for a peer that never sends + notices. Soul's probe becomes Cut F's regression test. + - C# (`CultNetTransport.cs:921`) and Kotlin (K:2645) have no fragment-set bound at all. **Cut 2c** brings + C# to the same bound; Kotlin gets it in K1 and the gated rule in K3. -### Cut 3. The sender's flow window: 1,023 sequences and 4 MiB above the lowest unacked — ready +### Cut 2c. C# gets the fragment-set bound (with Cut 2's rule) — merges with Cuts 2 and F + +- **Why in this campaign.** Rust, TS and Python bound pending fragment sets at 64 (`d3bc6e66` lineage; + `rudp.rs:328-340`, `rudp.ts:167`, `transport.py:430`); C# keeps `_fragmentBuffers` + (`CultNetTransport.cs:921`) unbounded, so a hostile peer grows it without limit and the parity doc cannot + call C# conforming. A runtime missing a sibling's protection is a gap to fill. +- **Repo/branch:** CultLib `hands/ack-cut2c`, from `main`; merges together with Cuts 2 and F (the gate + lives in F). +- **First.** C# ports of `d3bc6e66`'s lossy-set test and Cut 2's P5 test. +- **Adds:** `MaxPendingFragmentSets` (default 64, a setter as Rust's `set_max_pending_fragment_sets`, + `rudp.rs:328-340`), a per-set last-touched ordinal, eviction of the stalest set in `Reassemble` + (`:1556`), `FragmentSetsEvicted` beside `OutstandingReliablePacketCount` (`:993`), and Cut 2's rule (a + reliable set is not evicted; if every set is reliable the session is refused through `EndRefused`), + gated as in Cut F. +- **Verification:** the two tests; `GameCult.Networking.Tests` green; Stryker.NET on the diff. +- **Ledger.** About +45 and 0. + +### Cut 3. The sender's flow window: 1,023 sequences and 4 MiB above the lowest unacked — ready (merge after Cut 1) - **First.** Failing tests P8 (all runtimes) and P4 (Rust receiver, with a sender from each runtime). - **Change.** Admission (`AdmitReliablePackets` / `PromoteQueuedReliable` at @@ -655,7 +993,8 @@ Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending pr - its sequence is at most the lowest unacked sequence plus 1,023; - the admitted payload bytes above the lowest unacked sequence stay within 4 MiB. - The first packet is always admissible. Expired packets (Rust) do not count as unacked here: they are - gone from the wire. + gone from the wire. (Cut F refines this: an abandoned sequence counts while its notice is outstanding, + and stops counting once acknowledged or stranded.) - **Contract.** `docs/cultnet-transport-parity.md` (after `:263`) states the receive window: a receiver must hold 1,023 sequences and 4 MiB beyond its contiguous watermark, and a conforming sender never exceeds that. @@ -666,26 +1005,195 @@ Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending pr - the Rust benchmark or media probe shows no throughput change on a lossless link. - **Ledger.** About +25 per runtime, and nothing removed. -### Cut 4. Rust receipts: Acknowledged means delivered, resolved once; honest expiry and queueing — blocked on Cuts 1 and 1b, and on Q-A3 +### Cut D. A failed send to one peer is that peer's lost datagram (Rust document server and hub, C# listener, cultmesh-py server) — ready -- **First.** Failing tests: P3 (the receipt for `s2` reads `Pending` until `s1` arrives, then - `Acknowledged`), P9, P10, P2 (`flush_reliable` must fail), and: a receipt `Acknowledged` before any - ending stays `Acknowledged` after the ending and after a reconnect; a `Pending` one becomes - `Invalidated`. +Design in §10. Independent of every other cut; it touches send sites, not session logic. + +- **Repo/branch:** CultLib `hands/ack-cutD` from `main` at `e9469cb3` or later. Merge cost only with Cut 1c + in the Rust hub's and C# listener's Connect arms. +- **First.** Failing tests, one per server, each with two peers where every send to peer X fails: + - cultmesh-rs document server: `poll_once` never returns `Err`; peer Y connects, publishes, is acked and + gets a snapshot response; `maintain` returns `Ok`; the failure count rises; X's session ends by idle + timeout, not by the failure. Also: `recv_from` returning `ConnectionReset` is `Idle`; + - Rust hub: `poll_resends` and `receive_event_once` return `Ok` and keep serving Y; + - C# listener: `PollResends` reaches Y after X throws; `TryReceiveOnce` serves Y; + - cultmesh-py server: the RUDP thread survives an `OSError` from `sendto` and keeps serving Y. + - **The failure seam.** The Rust servers own a `UdpSocket` directly, and a loopback peer's address + cannot be made to fail on demand. Add the narrowest seam at the one send function: a + `#[cfg(test)]` set of addresses whose sends fail with a synthetic `io::Error` in `send_packet` + (`rudp_document_server.rs:628-632`, `rudp.rs:1799-1804`), and the same as an internal test hook in C# + `SendPacket` (`CultNetTransport.cs:2829-2834`). Python patches `socket.sendto` in the test. A socket + port trait would be the full dependency-injection answer; it is not bought here, because the only + consumer is this test. +- **Changes:** + - cultmesh-rs `rudp_document_server.rs`: `send_packet` (`:628-632`) keeps encode errors as errors and turns + a `send_to` failure into a counted loss; every per-peer call site drops its `?` on the send + (`:335`, `:373`, `:397`, `:451`, `:587`, `:603`); a `send_failures()` accessor beside `packets_dropped()` + (`:246-248`); `recv_from` treats `ConnectionReset` like `WouldBlock` (`:266-272`), as the socket + transport does (`cultnet-rs/src/rudp.rs:2059-2066`); + - cultnet-rs hub: `send_packet` (`rudp.rs:1799-1804`) likewise, counted in `CultNetTransportStats` + as a new `send_failures` field; `poll_resends` (`:1762-1777`) and the receive path (`:1644`, `:1673`, + `:1708`, `:1711`, `:1714`, `:1759`) stop propagating it; + - C# `CultNetRudpSocketTransportServer`: `SendPacket` (`:2829-2834`) catches `SocketException`, counts it + in `CultNetTransportStats.SendFailures`, and returns; `PollResends` (`:2796-2805`) and `TryReceiveOnce`'s + sends (`:2687`, `:2704`, `:2749-2750`, `:2788`) need no other change; the refusal goodbye's own catch + (`:2739-2745`) becomes redundant and goes; + - cultmesh-py `server.py`: `_send_rudp_packet` (`:302-307`) catches `OSError` and counts it. +- **Not changed:** single-peer socket transports (a failed send already concerns only their one peer, and + their callers, flush among them, get the error); `cultmesh-ts`'s document server (Node reports send + failures on the socket's `error` event, `index.ts:5858`). +- **Authority map:** + - Owner: each peer's session owns its peer's fate (idle timeout, lifetime, refusal, Disconnect). A send + failure is an input to nothing but a counter. + - Forbidden writers: no per-peer send failure ends a poll, a loop, a thread or another peer's session. +- **Verification:** + - the four tests; the full `cultmesh-rs`, `cultnet-rs`, `GameCult.Networking.Tests` and `cultmesh-py` + suites; + - negative: `rg -n "send_packet\(.*\)\?;" packages/cultmesh-rs/src/rudp_document_server.rs` returns + nothing (the pattern matches only a propagated send; encode errors propagate inside `send_packet`); + - operator: an Odin build on this CultLib keeps serving presence while one peer's address is + unroutable. +- **Ledger.** About −15 and +40 (the counters, the seams); tests about +120. + +### Cut F. Abandonment notices (FORWARD-TSN, RFC 3758): every receiver honours one, Rust sends one — blocked on Cuts 1 and 3, on Q-A7 and Q-A9; merges together with Cut 2 + +Design in §6. Mapped for the recommended answers to Q-A7 (one sequence per notice) and Q-A9 (a capability +bit gates Cut 2's rule). + +- **Repo/branch:** CultLib `hands/ack-cutF` from `hands/ack-cut2` rebased on `main` after Cuts 1 and 3 + merge. Cut 1 gives the receiver `R` and the drain the notice feeds; Cut 2 is the fragment code the notice + drops sets from and the rule the capability gates; Cut 3 is the admission rule the notice amends. Cut 2 + and Cut F merge to `main` together (the Cut 2 Soul finding: Cut 2 alone refuses sessions with expiring + senders). Kotlin's half is Cut K3. +- **First.** Failing tests, in C#, Rust, TypeScript and Python: + - the golden: `Abandon` encodes to the §6 bytes and decodes back, beside the existing fixture + (`packages/cultnet-rs/tests/cultnet.rs:319`, `packages/cultnet-ts/test/cultnet.test.ts:311`, + `packages/cultnet-py/tests/test_cultnet.py:409`, `tests/GameCult.Networking.Tests/NetworkingTests.cs:366`); + - a type-4 packet with bit 4 decodes as `Abandon`; bit 4 on a Data packet is still ignored; + - receiver: a notice for a hole advances `R`, drains the held ordered frames behind it, and replies with + the ack for that sequence (FN, inverted: `[o3]` delivered by the notice, `o2` marked received); + - receiver: a notice for a received sequence changes nothing and still replies; + - receiver: a notice inside an incomplete fragment set's span drops the set; + - receiver: a notice more than 1,024 above the highest received is ignored with no reply; + - receiver: the late original after its notice is a duplicate: acked, not delivered; + - Rust sender: an expired pending or queued packet produces a notice at once and then once per resend + delay; an ack naming the sequence stops it; after 16 unacknowledged sends it stops and records + `first_stranded`; every ending clears both; + - Rust end to end (P2 inverted): reliable `media` with expiry is lost, then ordered `s1`, `s2` on + `schema`; after the notice both are delivered and `flush_reliable` succeeds; + - Rust, Cut 3's window: an outstanding notice holds the lowest-unacked point; a stranded sequence does not. + - **the Cut 2 regression**, from Soul's `soul_probe_expiring_reliable_frames_kill_receiver` + (`packages/cultnet-rs/tests/cultnet.rs:3192` at `3be97b34`): 100 default `media` frames, each losing + every fragment but the first. With a Cut F sender the receiver's pending sets return to zero after the + notices and the session lives; with a stand-in sender that never advertises the capability nor sends + notices (Q-A9 B), the receiver evicts the stalest set as before Cut 2 and the session lives; with a + capable peer that stops sending notices, the 65th pinned set still refuses (Cut 2's rule holds where it + is true); + - (Q-A9 B) a Connect or Accept with flag bit 4 records the peer as capable; one without does not; an old + peer's accept of a flagged Connect works (the pass 3 CAP probe, kept as a test). - **Deletes first:** - - the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, the check at `:291-293`, and what - Cut 1b left of the rotations); + - Rust: the plain drop in `purge_expired_reliable` (`rudp.rs:898-908`; the `retain` calls become a move + into the abandoned set; the expired counter stays); + - the doc on `reliable_expire_after_ms` (`rudp.rs:121-124`), which says the packet is dropped; + - `packages/cultnet-py/README.md:103-111`'s claim that the Python session has reliable expiry. It has + none; the claim is false today. +- **Adds, receiver (C#, Rust, TS, Python):** + - the `Abandon` packet type and its codec mapping: Rust enum `rudp.rs:51-59`, `encode_flags` + `:2466-2475`, `packet_type_to_code` `:2477`, `decode_rudp_packet` `:2448-2455`, `packet_type_from_code` + `:2571`; C# `CultNetRudpPacketType` (`CultNetTransport.cs:509`) and `CultNetRudpPacketCodec` `EncodeFlags` `CultNetTransport.cs:697`, + `Decode` `:676-690` (its `Enum.IsDefined` check runs on the wire byte, which stays 4); TS union + `rudp.ts:26` and the codec at `:1264-1320`; Python enum `transport.py:250`, code table `:342`, codec + `:1175-1240`; + - the `Abandon` branch in `receive`, after acks are applied and beside the Ack/Pong branch + (`rudp.rs:578-592`, `CultNetTransport.cs:1177-1187`, `rudp.ts:397`, `transport.py:612`): the range + check, the fragment-set drop over the fragment buffers (`rudp.rs:1001-1114`, `CultNetTransport.cs:1556`, + `rudp.ts:643`, `transport.py:825`), `RememberReceived` and the Cut 1 drain, and + `reply = create_ack_for_received(sequence)` (`rudp.rs:751-758`, `CultNetTransport.cs:1273-1279`, + `rudp.ts:492-497`, `transport.py:685`); + - transports skip their own ack for an `Abandon` packet where a delivering receive would draw one: + `rudp.ts:1089`, `transport.py:1087` (Rust and C# ack only reliable, Accept or Data and need nothing). +- **Adds, capability (Q-A9 B; all four runtimes):** + - `Connect` and `Accept` carry flag bit 4; the session records whether its peer's handshake packet had + it, and resets that with peer state. `create_connect` (`rudp.rs:369-381`, `CultNetTransport.cs:1001-1010`, + `rudp.ts:229-240`, `transport.py:473-483`) and `accept_connect` (`rudp.rs:383-411`, + `CultNetTransport.cs:1012-1059`, `rudp.ts:242-290`, `transport.py:485-525`) set it; the codec keeps bit 4 + on those types (today every decoder drops it); + - the fragment-set bound (Cut 2's eviction, `rudp.rs:1027-1047`, `rudp.ts:679-692`, `transport.py:836-842` + as Cut 2 leaves them) applies the never-evict rule to a capable peer and the pre-Cut-2 stalest-set + eviction to one that is not; + - the sender sends notices only to a capable peer; for another, an expired sequence is stranded at once. +- **Adds, sender (Rust):** + - `RUDP_ABANDON_NOTICE_SENDS = 16` beside `DEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS` (`rudp.rs:39`); + - the abandoned set and `first_stranded` on the session (fields beside `pending_reliable`, + `queued_reliable`, `rudp.rs:193-195`); + - `purge_expired_reliable` moves expired pending and queued sequences into the set; + - `due_resends` (`:797-807`) emits due notices after due packets and retires entries at the bound; + - `apply_acknowledgements` (`:964-971`) removes named sequences from the set; + - `outstanding_reliable_packet_count` (`:270-272`) counts the set; + - the ending primitive (Cut 1b) and `reset_peer_state` (`:344-358`) clear both; + - Cut 3's lowest-unacked point (in `admit_reliable_packets` / `promote_queued_reliable`, `:910-942` as + Cut 3 leaves them) includes the set and excludes `first_stranded`. +- **Docs.** `docs/cultnet-transport-parity.md`: the packet-type and flags rows of the header table + (`:486-487`) gain "`4` with flag bit 4 = abandon"; the golden goes after the canonical fixture + (`:505-517`); the bounded-send paragraph (`:260-273`) gains the notice rule and its resend bound. +- **Authority map:** + - Owner: the sender session's abandoned set decides which sequences are announced and for how long; the + receiver session decides what a notice changes. + - Inputs: expiry (sender); a decoded `Abandon` packet in the current generation (receiver). + - Outputs: notices (sender); a remembered sequence, drained frames and one ack (receiver). + - Derived: `first_stranded` is derived from the set's retirements and read by Cut 4; the expired counter + is report-only. + - Forbidden writers: nothing marks a sequence received without a packet except the `Abandon` branch; no + notice outlives its generation; no transport interprets bit 4. + - Shared paths: data, fragments, duplicates, Accept and notices all advance `R` through + `RememberReceived` and the one drain. + - Deletion line: the plain drop in `purge_expired_reliable` goes before the set is added. +- **Verification:** + - the tests above in four runtimes; + - FN re-run against the new build as a **negative** check: an old-receiver stand-in (a session that drops + `Abandon` packets) never acks, and the Rust sender strands the sequence after 16 sends; + - the TS interop harness gains one lane: a Rust peer sends reliable `media` with expiry whose first + packet the bridge drops, then ordered `schema` frames; the TypeScript receiver delivers the `schema` + frames (`packages/cultnet-ts/test/interop/cultnet-interop.test.ts`, beside the drop lanes); + - negative grep: `rg -n "first_abandoned" packages src` returns nothing (pass 2's name must not appear); + - mutation testing on the diff. +- **Ledger.** Receiver about +40 per runtime (four); Rust sender about +60 and −6; the golden about +25 + per runtime; docs +15 and −8 (the false Python claim). + +### Cut 4. Rust receipts: Acknowledged means delivered, resolved once; honest stranding and queueing — blocked on Cuts 1, 1b and F + +Re-mapped in pass 3 for Q-A3 B. Pass 2's `first_abandoned` (every expiry leaves a permanent hole) is +replaced by Cut F's `first_stranded` (only a notice the receiver never acknowledged leaves one). The rest is +pass 2's cut. + +- **Repo/branch:** CultLib `hands/ack-cut4` from `main` after Cuts 1, 1b and F merge. +- **First.** Failing tests: + - P3: the receipt for `s2` reads `Pending` until `s1` arrives, then `Acknowledged`; + - P9 (`send_reliable` on an expiring channel is refused) and P10 (a window-full `send_reliable` returns a + receipt covering its queued packets, and the frame is sent once); + - a receipt `Acknowledged` before any ending stays `Acknowledged` after the ending and after a reconnect; + a `Pending` one becomes `Invalidated`; + - **P2 with a new receiver:** reliable `media` with expiry is lost, then ordered `s1`, `s2`; their + receipts read `Pending` while the notice is outstanding and `Acknowledged` once it is acked; + `flush_reliable` succeeds; + - **P2 with a deaf receiver** (a stand-in session that drops `Abandon` packets): after 16 notice sends the + receipts for `s1` and `s2` stay `Pending`, `flush_reliable` fails at once naming the stranded sequence, + and the ending makes them `Invalidated`. +- **Deletes first:** + - the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, the check at `:291-293`, and what Cut 1b + left of the rotations); - the stale comment and the half guard at `rudp.rs:1915-1920`; - the hand-rolled status loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`. - **Adds:** - the receipt's status cell and ordered flag; the session's unresolved-receipt list, resolved once (Acknowledged through `A`, Invalidated by the ending primitive); - - `acked_through` (`A`), reading pending, queued and first-abandoned; - - first-abandoned, recorded in `purge_expired_reliable` (`rudp.rs:898-908`); + - `acked_through` (`A`): one below the lowest sequence that is pending, queued, in Cut F's abandoned set, + or `first_stranded`; - the receipt covers queued sequences: `send_many` (`:456`) reports every sequence it assigned; - `send_reliable` (`:1903-1930`) refuses a channel with expiry; - `wait_acknowledged`; - - `flush_reliable` fails fast when an ordered send follows the first abandoned sequence. + - `flush_reliable` fails fast when an ordered send follows `first_stranded`, and otherwise waits while + notices are outstanding (Cut F counts them in `outstanding_reliable_packet_count`). - **Unchanged:** `CultNetRudpReliableSendStatus` (`:146-150`), `reliable_send_status`'s signature (`:1931`), and every caller of `Acknowledged`: `tests/rudp_stray_packets.rs:309`, `cultmesh-rs/tests/rudp_document_server.rs:408,415`, CodexConnector, Ghostlight. @@ -693,11 +1201,11 @@ Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending pr delivered to the document server's sink. - **Verification:** - the new tests pass; - - `rg -n "session_scope" packages/cultnet-rs` has no hit; + - `rg -n "session_scope|first_abandoned" packages/cultnet-rs` has no hit; - both suites are green; - cargo-mutants on the diff. -- **Ledger.** About −90 (two loops of about 30 each, the scope) and +90 (of which `wait_acknowledged` is - about 35). +- **Ledger.** About −90 (two loops of about 30 each, the scope) and +85 (of which `wait_acknowledged` is + about 35). Five lines smaller than pass 2: the abandoned bookkeeping moved to Cut F. ### Cut 5. CultLib-internal TS publishers and the contract text (TS, docs) — blocked on Cut 3, and on Cut 1b in TS @@ -722,24 +1230,164 @@ Mapped for Q-A6's recommended answer A. Blocked on Cut 1b (it uses the ending pr - a new test shows the publisher's peer receives a Disconnect. - **Ledger.** About −270 and +50. +### Cuts 6a, 6b, 6c. Receipts in C#, TypeScript and Python — blocked on Cut 4, and on Cut 1b in each runtime -## Subtraction ledger (estimate, four runtimes) +Design and public API in §9. One cut per runtime, in parallel worktrees, so Soul can falsify each alone. +Kotlin's is Cut K4. + +- **Repo/branch:** CultLib `hands/ack-cut6a` (C#), `hands/ack-cut6b` (TS), `hands/ack-cut6c` (Python), each + from `main` after Cut 4 merges. Cut 4 is the reference; Cut 1b supplies each runtime's ending primitive. +- **First.** Failing tests, in each runtime: + - an ordered receipt reads Pending while a sequence below it is missing, then Acknowledged (P3); + - a send made while the window is full returns a receipt that covers its queued packets and becomes + Acknowledged; the frame is sent once (P10's shape); + - an Acknowledged receipt stays Acknowledged after the session ends and after a reconnect; a Pending one + becomes Invalidated and stays so; + - the wait returns on Acknowledged, fails at once on Invalidated with the end reason, and times out; + - `SendReliable` on an unreliable channel is refused. +- **Deletes first:** nothing. These runtimes have no receipt code to replace. +- **Adds, per runtime:** + - C# (6a): `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`; in `CultNetRudpSession`, a send path that + records every sequence `SendMany` assigns (`:1075-1140`, before `AdmitReliablePackets` `:1439-1465` + splits them), the unresolved list, resolution after `ApplyAcknowledgements` (`:1503-1516`) and in the + Cut 1b primitive, and `A` over `_pendingReliable` and `_queuedReliable`; on the socket transport, + `SendReliable`, `WaitAcknowledged` (the `FlushReliable` loop, `:1914-1955`), and `SendSchema` + (`:1960`, `:1968`) and `SendSchemaMessage` (`:1976`) returning the receipt; + - TS (6b): `CultNetRudpSendReceipt`, the status union; session changes at `sendMany` (`rudp.ts:304-368`), + `#admitReliablePackets` (`:586-592`), `#applyAcknowledgements` (`:610-617`); transport + `sendReliable` and `waitAcknowledged` beside `send` (`:899-909`) and `flush` (`:911-935`); exported from + the package index; + - Python (6c): `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`; session changes at `send_many` + (`transport.py:538-588`), `_admit_reliable_packets` (`:776-786`), `_apply_acknowledgements` + (`:803-807`); transport `send_reliable` and `wait_acknowledged` beside `send` (`:1000-1009`) and + `flush_reliable` (`:1101-1131`); exported from `cultnet_py`; + - README sections per §9, and the parity doc's flush paragraph. +- **Authority map:** the session owns each receipt's status and writes it once; the transport reads it. + Forbidden writers: no transport, flush or timer writes a status; no receipt resolves from a packet's own + ack when it is ordered (only through `A`). +- **Verification:** the tests above; each runtime's full suite; the Cut 4 behaviour table held side by side + with Rust's (same inputs, same states); negative: in each runtime, the only assignment to the status is + in the session's resolve and end paths (`rg` for the status field outside those two functions returns + nothing); mutation testing on each diff (Stryker.NET, StrykerJS, mutmut). +- **Ledger.** About +90 per runtime, nothing removed; tests about +80 per runtime. + +### Cut 7. Reliable expiry in C#, TypeScript and Python — blocked on Q-A8 and Cut F + +Mapped only for Q-A8's recommended answer A. Each runtime gains Rust's send option (a per-send reliable +expiry, and the profile's `media_reliable_expire_after_ms`, `rudp.rs:2492-2508`) and Cut F's sender half +(the abandoned set, notices at the resend delay, the 16-send bound, `first_stranded`), and its receipts +(Cut 6) refuse an expiring channel. Kotlin's is K5. About +70 per runtime. If Q-A8 is B, this cut is not +built and the parity doc records Rust as the only runtime with partial reliability. + +### Cut K. Kotlin catches up (Q-A5 A, ruled) — K0 ready now; K1-K5 trail the four-runtime cuts + +`CultMesh.kt` (6,332 lines, the only RUDP file) has none of the `3bf1c0c` stray-packet hardening, the P1 +and P6 receiver, no reset path and no repeated-Connect answer. K is split so each step is falsifiable on its +own; the anchors below are `e9469cb3`, where `CultMesh.kt` is identical to `3bf1c0c`. Tests follow the +file's existing pattern (private self-test functions called from `main`, K:3349-3373); moving them out of +the production source is a follow-up. + +**K0. The Kotlin verify image and a Linux build path — ready now.** +- **Stopgap tooling** (`C:\Users\Meta\.claude\skills\eureka\tools\stopgap\`, owned by Self, not a CultLib + commit; it dies with `ygg-verify.sh`): + - `kotlin.Dockerfile`: `FROM eclipse-temurin:21-jdk`; `apt-get install unzip curl`; fetch + `https://github.com/JetBrains/kotlin/releases/download/v2.2.21/kotlin-compiler-2.2.21.zip` (the + version CI pins, `.github/workflows/cultnet-interop.yml:20`) into `/opt/kotlinc`; `ENV + KOTLIN_HOME=/opt/kotlinc PATH=/opt/kotlinc/bin:$PATH`; Node 24 copied from the `node:24` image + (`COPY --from=node:24 /usr/local /usr/local`) for the interop lane; PowerShell 7 (`pwsh`, Microsoft's + Debian package) for `build.ps1`. Pass 3's probe proved the JDK-plus-compiler half: the self-tests exit 0 + (K-img). + - `ygg-verify.sh`: a `kotlin` image alias tagged by that Dockerfile's hash, and the remote build step + generalised from `eureka-verify-rust:*` to `eureka-verify-:*` built from `.Dockerfile` + (today it builds and copies only `rust.Dockerfile`). Edit it by writing a temp file and renaming it over + the old one (the script's own rake). +- **CultLib:** + - `packages/cultmesh-kotlin/build.ps1` becomes portable PowerShell 7: `kotlinc` or `kotlinc.bat` by + platform, `[IO.Path]::PathSeparator` for the classpath, `java` or `java.exe`. One build script stays the + one owner of the Kotlin build; no `build.sh` beside it. + - The interop harness calls `pwsh` off Windows instead of `powershell` + (`packages/cultnet-ts/test/interop/cultnet-interop.test.ts:2799-2809`); it already picks `java` off + Windows (`:31-36`). + - `docs/cultnet-transport-parity.md` says, beside the runtime table (`:35`) and the RUDP claims + (`:205-273`), that Kotlin lacks the `3bf1c0c` hardening and the ack cuts until Cut K lands, so nobody + reads it as conforming meanwhile. +- **Verification:** `ygg-verify.sh kotlin 'cd packages/cultmesh-kotlin && pwsh -File build.ps1'` + runs the self-tests green; the interop lane + (`node --test --test-name-pattern "TypeScript and Kotlin|Kotlin and TypeScript"`) runs green on the same + image; CI on `windows-latest` stays green with the same script. + +**K1. Stray-packet parity** (the `3bf1c0c` behaviours the other four have, each with its test ported from +the C# or TS equivalent): +- a malformed, foreign-id or refused datagram is dropped and counted, never thrown out of `receiveOnce` + (K:3223, K:3228, K:2724, K:2567), with a `packetsDropped` stat (`CultNetTransportStats`, K:774); +- the connection id is checked before the endpoint is claimed, and in server mode only a Connect claims it + (K:3217-3222); +- `resetPeerState` and `endRefusedSession`: the refusing session resets, builds its goodbye after the + reset, and a server forgets its endpoint; a refused packet's sequence is remembered only after + reassembly accepts it (today K:2758 remembers before `reassemble` can throw, K:2915-2922); +- `answerRepeatedConnect`: a repeated Connect queues nothing and is answered with the owed Accept or an Ack + (today every Connect makes a new tracked Accept, K:2667-2676, K:3224-3226, K:4005-4006); +- a Connect from another endpoint replaces the peer in server mode (today ignored, K:3220-3221); +- a peer's Disconnect drops owed writes (K:2745-2748); `connect` clears a stale disconnect reason (K:3106); +- `initialSequence` of `0xFFFFFFFF` is refused at construction (K:2631) and sequence exhaustion is checked + before allocation (K:2841, K:2698-2711); +- the decoder's negative declared length (K:3306-3317) is an ordinary malformed drop; +- the fragment-set bound of 64 (K:2645, K:2911-2937 has none): the stalest set is evicted. Cut 2's + never-evict rule and its capability gate come in K3 with Cut F, as in the other runtimes. + +**K2. Cuts 1, 1b and 1c.** +- Cut 1: delete `orderedNextSequenceByChannel` (K:2643), `expectedSequenceIfUninitialized` (K:2728), + `deliverOrdered`/`drainOrdered`/`skipReceivedNonChannelSequences` (K:2939-2978); seed `R` in + `acceptConnect` (K:2667) and on Accept (K:2730); advance in `rememberReceived` (K:2891). +- Cut 1b: the ending primitive from a peer's Disconnect (K:2745), `createDisconnect` (K:2809), + `checkTimeout` (K:2814-2819) and K1's refusal; `flushReliable` (K:3186-3205) binds to the generation. +- Cut 1c: random defaults at K:2600, K:2996, K:3078 and the server's 100 at K:3016 + (`java.security.SecureRandom`); the session's repeat decision replaces server mode's + every-Connect `acceptConnect` (K:3224-3226) and the interop server's (K:4005); `createConnect` (K:2660) + and the Accept check (K:2730). + +**K3. Cuts 2, 3 and F.** Admission at K:2856-2872 gains the flow window; Cut 2's reliable-set rule behind +the Q-A9 capability; the `Abandon` type and receiver branch (K:2556-2567, K:2738, K:3296-3325), the golden beside `rudpPacketCodecUsesDeterministicReliableOrderedFixture` +(K:5236), and the transport skips its own ack for `Abandon` (K:3243-3245). + +**K4. Receipts** (§9): `sendReliable`, `CultNetRudpSendReceipt`, `CultNetRudpSendStatus`, `waitAcknowledged`, +at `sendMany` (K:2685-2719), `admitReliablePackets` (K:2856-2862), `applyAcknowledgements` (K:2882-2889) +and the transport (K:3132-3205). + +**K5. Reliable expiry and the notice sender** — only if Q-A8 is A (as Cut 7). + +- **Verification, each step:** the Kotlin image's self-tests and interop lane; the step's ported tests; + a negative grep per step (K2: `rg -n "orderedNextSequenceByChannel|expectedSequenceIfUninitialized" + packages/cultmesh-kotlin` returns nothing). +- **Ledger.** K1 about +180 and −40; K2 about −70 and +90; K3 about +80; K4 about +90; K5 about +70. + + +## Subtraction ledger (estimate) | Cut | Removed | Added | Other | |---|---|---|---| | 1 | ~280 | ~160 | four per-channel state machines become one watermark | | 1b | ~40 | ~45 | one ending primitive; TS's transport generation pair goes | -| 1c | ~60 | ~70 | four transport-level repeat decisions become one session predicate | -| 2 | ~10 | ~35 | none | +| 1c | ~90 | ~85 | seven transport- and server-level repeat decisions become one session predicate; two CultLib production pins go | +| 2 | ~10 | ~35 | none; merges with Cut F | +| 2c | 0 | ~45 | C# gains the fragment-set bound the other runtimes have | | 3 | 0 | ~100 | a documented receive-window contract | -| 4 | ~90 | ~90 | one public method (`wait_acknowledged`); the session scope goes; no enum change | +| D | ~15 | ~40 | one failure counter in three servers; a test seam at each send function | +| F | ~15 | ~350 | **wire: one packet type (`Abandon` = Ack + flag bit 4) and one golden in every runtime; with Q-A9 B, the same bit on Connect and Accept**; the false Python expiry claim goes | +| 4 | ~90 | ~85 | one public method (`wait_acknowledged`); the session scope goes; no enum change | | 5 | ~270 | ~50 | two hand-rolled RUDP loops and cultmesh-ts's connect wait go | +| 6a-6c | 0 | ~270 | public receipt API in three runtimes (Q-A4 A) | +| 7 | 0 | ~210 | only if Q-A8 is A: partial reliability in three runtimes | +| K0-K5 | ~110 | ~510 | Kotlin reaches parity; one verify image; `build.ps1` portable | -- **Source, net:** about −195. Tests are extra: about 4 × 10 rule tests. -- **Wire:** zero bytes. -- **Downstream:** no caller has to change code at its next bump. Callers that fixed `initial_sequence` - keep a deterministic Connect sequence, and with it the restart lockout, until they drop the value. -- **Kotlin (Q-A5 A):** Cuts 1, 1b, 1c and 3 again, about the same size as one runtime each. +- **Source, net, four runtimes without Cut 7:** about +325. Pass 2 was about −195; the rulings bought + FORWARD-TSN with its capability gate (about +335 net) and receipts in three runtimes (about +270); Cut D + adds about +25 and Cut 2c about +45. Tests are extra: + about 4 × 25 rule tests. +- **Kotlin:** about +400 net, most of it the `3bf1c0c` hardening the other four already carry. +- **Wire:** one additive packet type, ignored by every current peer (FN). +- **Downstream:** no caller has to change code at its next bump. Callers that pin `initial_sequence` keep + a deterministic Connect sequence until they drop the value (Cut 1c, pin-bump notes). ## Operator questions @@ -748,146 +1396,148 @@ Each question stands alone. **Ruled (2026-09-30), recorded so they are not re-asked:** -- **Q-A1. Where does delivered come from?** (a): derived at the sender from the cumulative watermark. No - wire change. +- **Q-A1. Where does delivered come from?** (a): derived at the sender from the cumulative watermark. - **Q-A2. One flush, or a "received" flush beside it?** One meaning: Acknowledged means delivered. - Receipts are Pending, Acknowledged or Invalidated. Retransmission stops on each packet's own ack. Flush - waits for Acknowledged. +- **Q-A3. Reliable expiry next to ordered traffic (P2)?** B: FORWARD-TSN now (Cut F). The recommendation + was A (report honestly, document "don't mix"). +- **Q-A4. Receipts in C#, TypeScript, Python and Kotlin?** A: port them in this campaign, after Cut 4 + (Cuts 6a-6c, K4). The recommendation was B (record a parity gap). +- **Q-A5. Is Kotlin in the campaign?** A: yes, as trailing Cut K on a Yggdrasil Kotlin image (K0). +- **Q-A6. How does a session recognise a new generation?** A: the Connect's sequence identifies it; the + default initial sequence is random in [1, 2^31) (Cut 1c). **Open:** -- **Q-A3. Reliable expiry next to ordered traffic (P2).** An expired reliable packet leaves a permanent - hole, and every ordered frame after it on the same session is held until the session ends. - - **A.** Report it honestly (Cut 4): ordered receipts after the hole stay `Pending` and become - `Invalidated` when the session ends, and flush fails fast. Document "do not send ordered traffic on a - session that uses reliable expiry". No live consumer does. - - **B.** PR-SCTP's FORWARD-TSN (RFC 3758). On expiry the sender sends an abandonment notice, which rides - an Ack-typed packet with flag `0x10` so that old peers ignore it (P7). New receivers treat the sequence - as received with no frame, and acknowledge it. The sender resends the notice until it is acknowledged. - That is a new cut in every receiver. Old receivers never acknowledge it, so the resend needs its own - bound. - - **C.** Refuse ordered sends on a session after its first expiry. - - **D.** Delete reliable expiry. Muninn's `audio` is its only user and `hands/media-fec-cut3` retires it. - This subtracts, but CultLib would lose partial reliability, which a reasonable consumer can expect. - - **Recommended: A now.** Record B as the complete answer for when a consumer mixes the two. - - **Blocks:** Cut 4 only. A keeps Cut 4 as mapped (`first_abandoned` alone). B adds a wire cut in every - receiver before Cut 4. C changes Cut 4's `send_many` guard. D replaces Cut 4's expiry half with a - deletion. Cuts 1, 1b, 1c, 2, 3 and 5 are unaffected. +- **Q-A7. Does an abandonment notice name one sequence, or a new cumulative point? (new in pass 3)** + RFC 3758's FORWARD TSN carries a *New Cumulative TSN*: "every missing sequence up to here counts as + received". SCTP can do that because its SACK is cumulative too, so the next SACK confirms it. CultNet's + ack is not cumulative: it names the highest received sequence plus the 32 below it, or one exact older + sequence. So the two shapes cost different things: + - **A. One sequence per notice.** The receiver answers exactly as it would answer a data packet with + that sequence, and that ack stops the sender's notice as it stops a retransmit. No new ack field. The + cost is one small notice per abandoned packet per resend: an expired 20-fragment frame sends 20 + notices, again every resend delay until acked. + - **B. A cumulative point.** One notice covers every abandoned sequence up to it. But nothing on the + wire can confirm it: an ack naming the point might mean the original of that one packet arrived late, + while lower holes are still open. So B needs a second addition, a confirmation Ack (another flag bit) + carrying the receiver's watermark, which puts a cumulative watermark on the wire after all, the thing + Q-A1 kept off it. + - **Recommended: A.** It keeps the wire change to one flag, reuses the ack path unchanged, and the extra + notices only flow while packets are expiring, which is a lossy moment on one session. + - **Blocks:** Cut F (and K3). Nothing else. -- **Q-A4. Receipts in C#, TypeScript, Python and Kotlin?** - - Only Rust has per-send receipts. The other four offer flush and an outstanding count. - - **A.** Port receipts with the same three states (Pending, Acknowledged, Invalidated) and a - `WaitAcknowledged`, in this campaign. - - **B.** Record it as a parity gap. Flush covers every surveyed consumer. - - **Recommended: B.** No consumer outside Rust waits per send. A reasonable C# consumer might want it, but - it is additive later, changes no wire byte, and after Cut 4 the Rust design (a status cell resolved - once by the session) ports without a second design. - - **Blocks:** nothing mapped. A adds a cut of about +80 per runtime after Cut 4. +- **Q-A8. Should C#, TypeScript, Python and Kotlin gain reliable expiry? (new in pass 3)** Rust is the only + runtime that can send a reliable packet that gives up after a deadline (partial reliability, for live + media). Cut F teaches every runtime to *receive* the notice, because any of them can talk to a Rust + sender; only Rust can *send* one. A reasonable consumer of the C# or TS library can expect the same send + option. Today the profile field that configures it (`media_reliable_expire_after_ms`, + `rudp.rs:2492-2508`) exists only in Rust and cultmesh-rs. + - **A. Yes, in this campaign** (Cut 7 for C#, TS, Python; K5 for Kotlin): the send option, the profile + field's behaviour and Cut F's sender half. About +70 per runtime. + - **B. Not now.** The parity doc records Rust as the only runtime with partial reliability. + - **Recommended: A.** CultLib is judged by what a reasonable consumer expects (operator, 2026-09-30), and + after Cut F the sender half is small and already specified. It is independent of every other cut, so it + can go last. + - **Blocks:** Cut 7 and K5 only. -- **Q-A5. Is Kotlin in the campaign?** - - `cultmesh-kotlin` carries a fifth RUDP runtime with P1 and P6 (the same code, `CultMesh.kt:2939-2978`). - Its flush has no callers. The stray-packet work did not touch it, so none of those fixes reached it - (not probed in pass 2). Its only build path is - `build.ps1`, which needs Android Studio's `kotlinc` and JBR on Windows. Yggdrasil has no Kotlin image in - the verify list. - - **A.** Include it, as a trailing Cut K that applies Cuts 1, 1b, 1c and 3, verified on a Yggdrasil - Kotlin image added first (or on Starfire as a Windows-only job). - - **B.** Record it as parity debt, with the stray-packet gaps. Mark the parity doc so that nobody reads - Kotlin as conforming. - - **Recommended: A, with a Yggdrasil Kotlin image.** Wire parity is the library's promise. A runtime - that strands acknowledged frames should not stay listed as conforming. - - **Blocks:** Cut K only. Cuts 1, 1b, 1c and 3 proceed in four runtimes either way; pass 1 had Q-A5 - blocking their scope, and pass 2 removes that by making Kotlin trail. - -- **Q-A6. How does a session recognise a new generation? (new in pass 2)** Today a server treats a - Connect from a connected endpoint as a repeat by payload (Rust hub, C# listener) or unconditionally (C#, - TS and Python server mode), so a restarted client with the same payload is locked out (G1), and a - client's second `connect()` is not seen as new, so the client cannot safely forget the old server - session's sequences (G2). The Connect's bytes are identical for a retransmit and for a restarted process - that starts at the same initial sequence, so something has to differ. - - **A.** The Connect's sequence identifies the generation, and the default initial sequence is random in - [1, 2^31), as TCP's initial sequence number is (RFC 9293 §3.4.1, RFC 6528). A Connect repeats exactly - when it carries the current generation's Connect sequence. No wire change. The default of a public - option changes (from 1, or 100 for the C# listener); an explicit value still works for tests, and a - caller that fixes it keeps the lockout. - - **B.** Proof of receipt. A byte-identical Connect repeats until the server has seen the client - acknowledge the Accept; after that any Connect is a new generation. No default changes. But a delayed - duplicate of the original Connect that arrives after that ack replaces a live session and forgets what - it received, so its retransmits are delivered twice. - - **C.** The application's Connect payload identifies the generation (today's hub rule), and every - client must put a per-process nonce in it, as the provider layer's `clientSessionId` does. Server mode - adopts the same comparison. This leaves G1 as a documented obligation on every caller, including the - ones that send an empty payload (`cultmesh-rs/src/lib.rs:973`, the TS publishers). - - **Recommended: A.** It is the literature's answer, it is robust to delayed duplicates (they carry the - accepted sequence), and it moves the decision into the session, deleting four transport-level - decisions. - - **Blocks:** Cut 1c only. Cut 1b (the ending rule) is ruled and does not depend on it. +- **Q-A9. What does a receiver do with fragment sets from a sender that never sends abandonment + notices? (new in pass 3, from the Cut 2 Soul pass)** Cut 2 stops evicting reliable fragment sets, because + evicting one whose fragments were acknowledged loses a frame the sender saw acknowledged (P5). But a + sender with reliable expiry, which is every default Rust `media` sender, abandons the rest of a frame + after its first fragment was acknowledged, so the set can never complete; after 64 of them Cut 2 refuses + the session. Cut F's notices release those sets, but only a Cut F sender sends notices, and the receiver + cannot tell an old expiring sender's dead set from a live set still waiting for a retransmit. + - **A. No fallback; bump both ends together.** Cut 2's rule applies to every peer. Any session between a + Cut 2 receiver and a pre-F expiring sender dies after 64 lossy frames, so every such pair (Muninn's + media, Mimir's relay, browsers on vendored CultLib) must move in the same deploy. + - **B. A capability bit, as PR-SCTP negotiates (recommended).** Connect and Accept carry flag bit 4 + ("I send and honour abandonment notices", RFC 3758 §3.1 Forward-TSN-Supported). Old peers ignore it on + those packets (probe CAP, all five runtimes). Cut 2's never-evict rule applies to a peer that advertised + it; a peer that did not keeps today's stalest-set eviction, with today's P5 risk, which only its own + upgrade removes. The sender also stops sending notices to peers that cannot use them. Cost: one flag on + two packet types and one remembered bit per session. + - **C. Age-based release.** A reliable set untouched for some multiple of the resend delay is released + for every peer. It keeps sessions alive without a wire change, but it releases a live set whose missing + fragment is merely slow, which is the P5 lie Cut 2 exists to remove, now on a timer. + - **Recommended: B.** It is the literature's answer, it makes Cut 2's rule hold exactly where its premise + holds, and mixed versions keep working without coordinated deploys. + - **Blocks:** Cut F's capability half and therefore the merge of Cuts 2, 2c and F. Q-A7 is independent. **Not forks, recorded so they are not re-asked:** - `Acknowledged` keeps its name and now means delivered. No variant is added or removed. - Delivered means handed to the transport's application boundary, not consumed. -- The 4 MiB cap stays. The sender's window keeps legal traffic under it (§5). -- The receiver's 4,096 receive history stays. The sender's window keeps legal traffic inside it. -- A receipt is resolved once. `Acknowledged` before a session ends stays `Acknowledged`; anything else - becomes `Invalidated` at the end. +- The 4 MiB cap stays; the receiver's 4,096 receive history stays. +- A receipt is resolved once. - Session generation is its own two cuts, not part of Cut 1 (§8). +- The notice's resend bound is 16 sends at the resend delay (§6). It is a constant, not an option. +- Receipts live on the single-peer socket transport in every runtime, as in Rust; hub receipts are a + follow-up for all five together. +- A failed send to one peer is that datagram lost (Cut D, RFC 1122's soft errors); it does not end the + peer's session. ## Sequencing and blockers -1. **Done.** `hands/rudp-stray-packets` merged to `main` at `3bf1c0c`. Odin pins `3bf1c0c`. -2. **Ready for Hands now** (base `main` at `3bf1c0c` or later; four runtimes; Kotlin trails per Q-A5): - - **Cut 1** (the watermark). - - **Cut 1b** (the ending rule). Same files as Cut 1 in `ResetPeerState` and the Disconnect branches: - land it after Cut 1 on the same branch, or in parallel and rebase the second. - - **Cut 2** and **Cut 3**, independent of Cut 1 and of each other, in parallel worktrees. Merge Cut 1 - before Cut 3 so that Cut 3's P4 test exercises the new hold path. -3. **Cut 1c** is blocked on Q-A6 and on Cut 1b. -4. **Cut 4** is blocked on Cut 1 (Acknowledged is sound only against a correct receiver), on Cut 1b (it - resolves receipts through the ending primitive), and on Q-A3. -5. **Cut 5** is blocked on Cut 3 in TS (the socket transport it routes through must carry the window) and - on Cut 1b in TS (its flush must be bound to the generation). It does not need Cut 1c, but Cut 5's - parity-doc text for repeated Connects waits for it. -6. **Odin's next pin bump** follows Cuts 1-3 (and 1b). Odin is the receiver for every Idunn health, - presence and catalog publisher, so this bump is what makes their Acknowledged mean delivered. Odin uses - `publish_cultnet_message_to_rudp_catalog` and no receipt type, so Cut 4 never breaks its build. -7. **Parallel campaigns:** - - **`hands/media-fec-cut3`** (unmerged, `4b85a173`) touches `rudp.rs` only in the imports, the - profile's `audio` entry (`~2374` at `3bf1c0c`) and the `"media" | "audio"` arm of - `channel_send_options` (`~2547`). None overlaps Cuts 1-4. Either order; the second rebases trivially. - Once it lands, reliable expiry is only for `media` when the caller chooses Reliable. - - **Muninn's own cuts** use lossy media plus parity. Its reliable `audio` session has no ordered - traffic, so P2 does not bite it. Nothing here blocks Muninn, and nothing in Muninn blocks this. - - **CultCache publication Cut 4** (`docs/cultcache-publication-cut.md` §4) touches `CultNetDatabase.cs` - only, and needs in-order, exactly-once delivery on `schema`. The C# socket transports carry one - reliable channel (`CultNetTransport.cs:2372-2376`), so P1 and P6 cannot occur on their sessions. No - blocking relation in either direction. -8. **Vendored TS bumps come after Cut 5**, so they pick up honest flushes, the TS window and the rewritten - publishers at once: - - **StreamPixels** vendors CultLib at `30ee8b9` (StreamPixels `origin/main`, gitlink bumped in - `44a8945`). Its service runs `createIdunnRuntimePresencePublisher` - (`apps/service/scripts/idunn-entry.mjs`), whose file is byte-identical at `30ee8b9` and `3bf1c0c`; - `30ee8b9` also predates the TS stray-packet hardening in `rudp.ts`. - - **Heimdall** vendors `b6b1d9c` and hand-rolls its own publisher (`src/idunn-rudp-health.ts:55-87`, - first-ack wait). At its bump it calls cultnet-ts's publisher instead. - - Bumping either after Cut 1c as well also gives them the restart fix; neither needs it, because each - publish binds a new port. -9. **CodexConnector** and **Ghostlight** need no code change at their next bump. They may switch their - status loops to `wait_acknowledged` after Cut 4. +1. **Done.** `hands/rudp-stray-packets` merged at `3bf1c0c`. Odin `origin/main` pins `3bf1c0ce`. +2. **In flight** (pass 2 text, unchanged by pass 3): Cuts 1 and 1b on `hands/ack-cut1`, Cut 2 on + `hands/ack-cut2`. No ruling forces a change to their specs: + - Q-A3 B adds the abandoned set, but Cut F adds it and hooks it into Cut 1b's primitive; 1b need not know. + - Q-A4 A needs an ending primitive in C#, TS and Python, which 1b already builds. + - Q-A6 is Cut 1c's. + - **Cut 2's merge moves** (the Cut 2 Soul finding): its code stays as specified, but it refuses sessions + with expiring senders unless Cut F releases their dead sets, so it merges only together with Cut F + (and Cut 2c). Its spec's "a conforming sender cannot reach that bound" is corrected in place. +3. **Ready now:** Cut 3 (merge after Cut 1, as pass 2 said); **Cut D** (independent); **K0** (tooling and + `build.ps1`; independent). +4. **Cut 1c** after Cut 1b merges. +5. **Cut F** built on `hands/ack-cut2` after Cuts 1 and 3 merge, once Q-A7 and Q-A9 are ruled. **Cuts 2, + 2c and F merge to `main` together.** +6. **Cut 4** after Cuts 1, 1b and F. +7. **Cuts 6a, 6b, 6c** after Cut 4, in parallel. +8. **Cut 5** after Cut 3 and Cut 1b in TS, as pass 2 said. It may use 6b's `waitAcknowledged` if 6b lands + first; it does not need it (flush suffices). +9. **Cut 7** after Cut F, once Q-A8 is ruled A. +10. **Cut K** after K0: K1 anytime; K2 after Cuts 1, 1b, 1c; K3 after Cuts 3 and F; K4 after Cut 4 + (and Cuts 6 as the parity reference); K5 after Cut 7. +11. **Pin bumps:** + - **Odin** after Cuts 1, 1b and 3 (it is the receiver for every Idunn publisher, so this bump makes + their Acknowledged mean delivered) and Cut D (it is the document server's only production host). Cuts + 2 and F come together at whichever bump follows their merge. + - **Muninn** (and any consumer of the default reliable `media` channel) takes Cuts 2 and F together. + Under Q-A9 B its ends may move separately; under A they must move in one deploy. Its client and + Sleipnir pins of `initial_sequence: 1` should go at that bump (Cut 1c notes). + - **CodexConnector** and **Ghostlight** need no code change; they may adopt `wait_acknowledged` after + Cut 4. +12. **Vendored TS bumps after Cut 5** (they pick up honest flushes, the TS window and the rewritten + publishers at once): + - **StreamPixels** vendors CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale). + Its service runs `createIdunnRuntimePresencePublisher`, which that revision carries under its older + name (`cultnet-ts/src/idunn-runtime-authority.ts`, pin at `:333`). + - **Heimdall** vendors `b6b1d9c` and hand-rolls its publisher (`src/idunn-rudp-health.ts:55-87`, + `initialSequence: 1` at `:65`). At its bump it calls cultnet-ts's publisher instead. +13. **Parallel campaigns:** `hands/media-fec-cut3` touches `rudp.rs` only in the imports, the `audio` + profile entry and the `"media" | "audio"` send arm; no overlap with Cuts 1-4, D or F beyond a rebase. + Once it lands, reliable expiry is only `media` in Reliable mode. CultCache publication Cut 4 is + unrelated in either direction. ## Follow-ups outside this campaign - **Hold caps in C#, TS, Python and Kotlin.** Their ordered hold buffers and per-channel maps are unbounded - against a hostile peer. Rust has caps. + against a hostile peer. Rust has caps. The far-ahead refusal on data is Rust-only too. - **The document server's per-session payload budget never shrinks** (`rudp_document_server.rs:321`, - `:584`). After 4 MiB in one session it drops data without acknowledging it, and the sender retransmits - until it gives up. + `:584`). - **Fire-and-forget health.** Muninn `main.rs:9446`, Sleipnir `main.rs:880-904` and Heimdall's first-ack wait can lose a health record silently. -- **FORWARD-TSN** if Q-A3 is A and a consumer later mixes expiry with ordered traffic. -- **Callers outside CultLib that fix `initial_sequence`** keep the restart lockout after Cut 1c until they - drop the value. Record it in their pin-bump notes. -- **The P4 probe at the old pins** predates its fix, so it proves nothing there. If anyone needs - old-sender-to-new-receiver evidence for the cap, rerun `09318d13`'s P4 against a pinned sender. +- **Receipts on hubs** (Rust hub, C# listener, cultmesh-ts and cultmesh-py servers) in all five runtimes + together; and a peer-level `send`-and-wait in TS's `CultNetPeer` (`peer.ts:98-110`). +- **Kotlin self-tests live in the production source** (`CultMesh.kt` K:5236-5633, run from `main`). Move + them to a test source set once the Kotlin image exists. +- **Callers outside CultLib that pin `initial_sequence`** (Cut 1c's table): drop the value at each bump. +- **Mimir `CultMeshMedia`** hand-rolls a per-peer relay with its own Connect handling (`Program.cs:365`, + `:485`); it should call the session's repeat predicate after its CultLib moves. +- **`CultNetTransportStats` parity.** Cut D adds `send_failures` in Rust and C#; Kotlin has no + `packetsDropped` until K1. Record the stats shape in the parity doc once both land. +- **The P4 probe at the old pins** predates its fix; rerun `09318d13`'s P4 against a pinned sender if + old-sender evidence is needed.