CultLib/docs/cultnet-rudp-ack-cut.md
Meta Vens b04d03b6f1 Ack map: Cut D landed at 2f06ef84
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:44:13 +02:00

1637 lines
133 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CultNet RUDP: Acknowledged means delivered, one watermark owns ordered delivery, and a write belongs to its session
**PIVOT, 2026-09-30 (operator): RUDP is retired in favour of QUIC.** Operator: "RUDP really ballooned into
maintaining a robust custom transport across runtimes, when it started as just 'hey, let's do LiteNetLib but
better'. Is there any point maintaining it when we have QUIC?" and then "Yeah, pivot to QUIC adoption, I ain't
gonna let sunk costs take the wheel."
- **Landed 2026-09-30: Cut D, merged at `2f06ef84`** (Soul passes 1-4, batch 5 closing pass 4). It fixes live crashes: a spoofed datagram crashing Node, a malformed frame
killing Python's server, C# stale peers corrupting sessions.
- **Parked, not built:** Cut F, Cut 2 (held on F), Cut 4, Cut K and K1 (including the Kotlin resend-loop gap), Cut 7
and the receipts port. Nothing of theirs exists to tag, so this map is their record.
- Defects found in RUDP from here on are fixed only if they are live crashes or data loss, until the QUIC campaign
sets RUDP's retirement.
- **Data-loss item from Odin Soul S-5 (2026-09-30).** At Odin's pin `3bf1c0ce`, cultnet-rs records a reliable
packet as received when it arrives, and `create_ack` reports it while ordered delivery still holds an earlier
packet.
- Probe: two pipelined puts, with the first datagram dropped. The second put was ACKed but not on disk. If the
first never arrives, the ACKed second put is never delivered.
- Sessions that carry a single put are safe.
- **Checked (Odin Q5 Imagination, probe P1 at `e4e2a0b9`): still open after ack Cuts 1-1c.** A held, undelivered
put is ACKed, so the pin bump does not close it, and it is a data-loss fix under the pivot rule. Related, P2: a
Pong ACKs a put received before the Ping; P3: a reliable reply created after a put ACKs it. The Odin writer-thread
cut (C5) adds `acknowledgement_withheld` to the document server; the transport fix belongs in cultnet-rs.
- The "CultNet over QUIC" campaign is new, so it opens as a typed Eureka campaign in the Huginn-backed session, not
as a prose map here.
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
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. 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:** 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**.
- **Ruled after pass 3 (operator, 2026-09-30), each the recommendation:**
- **Q-A7 A:** a notice names one sequence, not a cumulative point.
- **Q-A8 A:** C#, TypeScript, Python and Kotlin gain reliable expiry (Cut 7, K5).
- **Q-A9 B:** a capability bit on Connect and Accept; Cut 2's never-evict rule applies only to peers
that advertise it, and other peers keep pre-Cut-2 eviction.
Cut F is unblocked once Cuts 1 and 3 merge; the text below mapped these answers already.
- **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 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)
- A frame the peer has buffered but not delivered must not look delivered. Today an ordered frame that
arrives behind a gap is held and acknowledged; if the session ends it is lost, and the sender saw
"Acknowledged".
- Resending stays bounded. The sender stops retransmitting a packet when the peer acknowledges that packet,
exactly as today. Delivery-only acknowledgement was rejected: "I don't like the possibility of unbounded
resending on an unresponsive peer application. TCP sounds like it has the right idea."
- One public meaning: **Acknowledged means delivered** to the peer's application boundary (§2). Unordered
reliable frames are delivered on receipt. Receipts go Pending, then Acknowledged, or Invalidated.
- A write belongs to the session it was issued in. When that session ends, a write not yet Acknowledged is
Invalidated. It is never carried into, retransmitted in, or acknowledged by a later session.
## The finding that shapes this map
Self's first proposal put a second acknowledgement on the wire: a per-channel "delivered through N"
watermark beside the received ack. Probing the receivers changed the picture in two ways.
**1. The ordered receivers are broken in every runtime, and the break is bigger than the session-end case.**
A held frame is not only lost at session end. In a live session it can be stranded forever, or dropped,
while the sender sees it acknowledged. The per-channel `next` pointer (`CultNetTransport.cs:918`,
`rudp.rs:196`, `rudp.ts:160`, `transport.py:424`, `CultMesh.kt:2643`) has been patched twice (`436654de`
added the first-use initialisation, `4335ab10` added the skip over other channels' sequences) and is still
wrong:
- **P1: a gap filled by another channel strands the ordered channel.** Ordered `o1`, reliable `u` on
another channel (lost), `o2`, `o3`. When `u` arrives, only `u`'s channel is drained. `o2` and `o3` are
never delivered, and the sender's pending set is empty. The same holds with two ordered channels (P1b).
- **P6: a channel first used after other traffic loses frames.** `C1`, `C2` on a new channel, then `X` on
another. `X` arrives, then `C2`, then `C1`. The channel initialises its `next` from
`min(highest+1, frame)`, so `C2` is delivered first and `C1` is **dropped as old**
(`frame.sequence < next`). It was acknowledged.
- Both reproduce at `3bf1c0c` in C#, Rust, TS and Python (pass 2), and did at `d85877a7`, Odin's old pin
`a8aeddac` and Muninn's pin `c2a9a6e5` (pass 1).
**2. Once the receiver is correct, "delivered" is not new information.** A conforming receiver holds an
ordered frame for exactly one reason: some reliable sequence below it has not arrived. When the last
missing sequence arrives, the same `Receive` call drains the held frames, before the acknowledgement is
built. So:
> An ordered frame is delivered exactly when every reliable sequence up to its last one has been
> acknowledged.
The sender already knows that. It is the sender's own **acknowledged-through** watermark: one below the
lowest sequence that is still pending, queued or abandoned. TCP's cumulative ACK means the same thing,
"every byte below N is in the receive buffer", and it sits beside SACK. Here the sender can compute the
cumulative value itself, because every sequence is acknowledged individually and removed from its pending
set. The operator ruled this (Q-A1 a), and then ruled that the per-packet ack is not a public meaning at
all (Q-A2): it only stops retransmission.
The derivation is sound only if nothing the receiver acknowledged is later dropped while the session
lives, and nothing one session acknowledges is credited to another. The probes found these places where
that fails:
| # | Where an acknowledged frame is lost, or credited to the wrong session | Runtimes | Fixed by |
|---|---|---|---|
| P1, P1b | A cross-channel gap strands the ordered channel | all five | Cut 1 |
| P6 | Late channel initialisation drops the earlier frame | all five | Cut 1 |
| P5 | A reliable fragment set is evicted after its fragments were acknowledged | Rust, TS, Python (C# and Kotlin have no eviction) | Cut 2 |
| P8 | A retransmit more than 4,096 sequences behind the receiver's highest counts as a duplicate: acknowledged and dropped | all five | Cut 3 (the sender never lets its span reach it) |
| G2 | After `connect()` again on the same transport, a new server session's frames are duplicates of the old one's sequences: acknowledged and dropped | C#, Rust, TS, Python | Cut 1c |
| G3 | A write left owed when a session ends is retransmitted into the next session and delivered there, and in Rust its receipt reads `Acknowledged` | Rust (local `disconnect()`, timeout); C#, TS, Python (every ending) | Cut 1b |
And places where the sender loses track, or is locked out, without the receiver's help:
| # | Sender-side lie or lockout | Runtime | Fixed by |
|---|---|---|---|
| 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 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
Everything ran on Yggdrasil through `ygg-verify.sh`, from local-only probe branches in throwaway
worktrees. Each probe commit survives on the Yggdrasil mirror as `refs/verify/<sha>`.
Pass 1 (base `d85877a7` and the pins; mirror `CultLib-imag-ack`):
- `de2bdfe8`, `09318d13`, `03eac6ff`: Rust session and socket probes (`packages/cultnet-rs/tests/probe_ack.rs`).
- `b70a69ca`, `2320a4e5`, `7fe5d726`: the C#, TS and Python probes (`ProbeAckTests.cs`,
`probe-ack.test.ts`, `probe_ack.py`).
- `f0e7136c`: the Rust probes at Odin's old pin `a8aeddac`. `bf8fc826`: all four at Muninn's pin `c2a9a6e5`.
- `db7d2886`: **the prototype**, the Rust receiver rebuilt on one watermark plus sender-derived receipt
states (about +130/−125 in `rudp.rs`). Its `Received`/`Delivered` receipt states are superseded by Q-A2;
its receiver is what Cut 1 builds.
Pass 2 (base `3bf1c0c`; mirror `CultLib-imag-ack2`):
- `a383e27d`, then `1519a20a` (the C# server-mode G1 probe acknowledges the Accept before the restart, so
it tests a restart and not a lost Accept): the pass-1 probe files restored unchanged, plus
`packages/cultnet-rs/tests/probe_gen.rs`, `packages/cultnet-ts/test/probe-gen.test.ts`,
`packages/cultnet-py/tests/probe_gen.py` and `tests/GameCult.Networking.Tests/ProbeGenTests.cs`.
- Rust and C# G1 run over real loopback UDP (hub, listener, server-mode transport), with the restarted
client bound to the first client's exact address. Python G1 runs over UDP against the server-mode
transport. TS G1 calls `acceptConnect` on the connected session, which is what the socket transport does
for a same-endpoint Connect (`rudp.ts:1034-1049` calls it without a reset, and `rudp.ts:249-251` answers
it as a repeat). G2 and G3 are session-level: the socket transports' `connect()` is `create_connect` and
nothing else (`rudp.rs:1874-1883`, `CultNetTransport.cs:1836-1846`, `rudp.ts:891-897`,
`transport.py:994-998`).
| # | Probe | Pass 1 (`d85877a7`, pins) | Pass 2 (`3bf1c0c`) | Prototype `db7d2886` |
|---|---|---|---|---|
| P1 | `o1`, reliable `u` lost, `o2`, `o3`; then `u`, then `o3` | Delivered `[o1, u]`; `o2`, `o3` never; sender pending `[]`. Rust, C#, TS, Python; same at both pins | Same, all four | `[o1, u, o2, o3]` |
| P1b | `A1`, `B1` (second ordered channel) lost, `A2`, `A3` | `[A1, B1]`, all four | Same, all four | `[A1, B1, A2, A3]` |
| P6 | `C1`, `C2` on a new channel, then `X`; arrival `X`, `C2`, `C1` | `[C2]`; `C1` dropped. All four | Same, all four | `[C1, C2, X]` |
| P2 | Rust: reliable media with expiry 1 ms, lost; then `s1`, `s2` on `schema` | Nothing delivered. Sender outstanding 0, expired 1, no resends | Same | Unchanged (held behind the hole). Cut 4 makes the sender say so |
| P3 | `s1` lost, `s2` arrives and is acknowledged | `s2` not delivered, but acknowledged: sender pending `[s1]` only | Same | Receipt for `s2` stays short of delivered until `s1` arrives |
| P4 | Rust: `s0` delivered, `s1` lost, then a 5 MiB ordered frame in 60 kB fragments | `RUDP ordered hold buffer is full`: the session is refused. With no gap, delivered | Same | The prototype held every ordered frame, so a 5 MiB frame was refused **even with no gap**: hold only what is not yet deliverable (Cut 1) |
| P5 | 3-fragment reliable frame, 2 fragments acked; 70 lossy fragment sets; last fragment arrives | Rust, TS, Python: never delivered, sender pending `[]` | Same, Rust, TS, Python | Unchanged (Cut 2) |
| P7 | An Ack with flag bit `0x10`, a nonzero sequence field, or a 5-byte payload | Decoded and applied in Rust, C#, TS, Python | Same, all four | n/a |
| P8 | Reliable `g` lost while 4,200+ later sequences are sent and acked; then `g` arrives | `g` not delivered, and acknowledged. All four | Same, all four | Unchanged (Cut 3) |
| P9 | Rust socket: `send_reliable("media")` with expiry 1 ms, never read | `Pending`, then `Acknowledged` | Same | `Pending` |
| P10 | Rust socket: 40 `schema` sends, then `send_reliable` | Error "receipts require a non-empty reliable packet set", after the frame was queued | Same (the probe's `?` fails on that error) | Not addressed (Cut 4) |
| G1 | Client A connects, sends one ordered frame (acknowledging the Accept), and exits. Client B, a fresh session with the same initial sequence, binds A's exact address and sends the same Connect payload five times | n/a | **Locked out.** Rust hub (payload `"svc"` and empty): 5 Acks, 0 Accepts, B unconnected, the hub still holds A's generation. C# listener: same, no `PeerDisconnected`. C# server mode: 5 Acks. Python server mode: 5 Acks. TS session: the answer is an Ack. Rust server mode (control): B gets an Accept and its frame is delivered, because it resets on every Connect | Not probed |
| G1b | C# and Python server mode: a third client on a new port sends a Connect | n/a | No reply; the datagram is counted dropped | Not probed |
| G2 | Client connects to server 1 (initial sequence 1) and receives `old1..3`. Server 1 is gone; the client connects again on the same session to server 2 (initial sequence 1), which sends `new1`, `new2` | n/a | **`new1`, `new2` never delivered, and acknowledged** (server 2's pending set empties). C#, Rust, TS, Python | Not probed |
| 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,
not mechanism:
- `rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads_in_sequence`
(`tests/cultnet.rs:1000`) hands the receiver an Accept with sequence 91 from a sender whose data starts
at 1. That handshake is impossible. Under a watermark seeded by the Accept, every data sequence is
"already below" it. The test must use a consistent handshake.
- `rudp_ordered_channel_tracking_is_bounded` (`:2655`) sends 256 ordered channels with no gap. The
prototype keeps state only for channels with held frames, so nothing is refused. The bound becomes
"channels with held frames", and the test must hold frames behind a gap.
- `cultmesh-rs` did not compile only because the prototype added a status variant. Under Q-A2 the status
enum keeps its three variants, so that break does not recur.
**Wire compatibility (P7).** Every decoder ignores flag bits 4-7 and byte 35. Every session ignores an
Ack's sequence field and payload (`CultNetTransport.cs:1177`, `rudp.rs:578-592`, `rudp.ts:397`,
`transport.py:612`, `CultMesh.kt:2738`). Every decoder rejects an unknown packet type
(`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 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.
- 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
- **Owner of ordered delivery (receiver): the session's contiguous received-through watermark `R`.**
- Every reliable sequence up to `R` has been received since the peer state was last reset.
- A held ordered frame whose first sequence is at most `R + 1` is delivered, in sequence order, in the
same call that advanced `R`.
- `R` is seeded by the handshake. `AcceptConnect` seeds it with the Connect's sequence, and receiving
the Accept seeds it with the Accept's sequence. A session connected without a handshake (Rust
`assume_connected`, `rudp.rs:243`, test-only today) seeds it from its first reliable packet.
- 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, 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
session stops being connected, every write not yet Acknowledged is Invalidated and dropped from pending
and queued, and the generation counter advances. Every ending path calls it: a peer's Disconnect, a
local disconnect, a timeout and a refused packet (Cut 1b), and from Cut 1c a client's `connect()` on a
session that already had a peer, and a server accepting a Connect that does not repeat the current one.
- What was learned from the peer (`R`, the received set, held frames) is forgotten only where both sides
start fresh: a refusal (as today) and, from Cut 1c, a non-repeated handshake. Forgetting it at a
one-sided ending (a timeout the peer did not see) would let the peer's retransmits be delivered twice.
- **Owner of "is this Connect a repeat": the session**, from the Connect's sequence (Cut 1c; Q-A6).
- **Inputs.** Received sequences and handshake packets (receiver); acks, expiry and ending events
(sender). Nothing else.
- **Outputs.** Delivered frames, in order (receiver). Receipt status and flush completion (sender).
- **Derived, not owners.**
- The per-channel `next` map is **deleted**, not demoted: `_orderedNextSequenceByChannel`
(`CultNetTransport.cs:918`), `ordered_next_sequence_by_channel` (`rudp.rs:196`),
`#orderedNextSequenceByChannel` (`rudp.ts:160`), `_ordered_next_sequence_by_channel`
(`transport.py:424`), `orderedNextSequenceByChannel` (`CultMesh.kt:2643`).
- `expectedSequenceIfUninitialized` and its equivalents are deleted (`CultNetTransport.cs:1151`,
`rudp.rs:540`, `rudp.ts:375`, `transport.py:595`, `CultMesh.kt:2728`).
- `PendingOrderedFrame.NextSequence` is deleted (`rudp.rs:160-163` and equivalents).
- `OutstandingReliablePacketCount` stays, and means "not yet acked in this generation". It is a
retransmission count, not a delivery claim.
- Rust `session_scope: Uuid` (`rudp.rs:135`, `:179`, `:206`, rotated at `:345` and `:601`) is no longer an
owner. It is replaced by the generation (Cut 1b) and then by receipts resolved at their source
(Cut 4).
- TS `#generation`, `#endedGeneration` and `#endGeneration` in the socket transport (`rudp.ts:830-831`,
`:937-940`) are no longer owners; the session's generation replaces them.
- `DisconnectReason` / `disconnect_reason` / `#endedReason` are report-only: why the last generation
ended. They no longer gate flush.
- **Forbidden writers.**
- 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, 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`), 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
primitive.
- **Deletion line.** `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences` and their
equivalents go before the drain is added: `CultNetTransport.cs:1628-1700`, `rudp.rs:1138-1246`,
`rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`. The transport-level repeat decisions
above go before the session's is added.
## The design, question by question
### 1. Where Acknowledged comes from (Q-A1 a, ruled)
- Derived at the sender from `A` (§ The finding). No wire change.
- **Why it is sound after Cuts 1-3.** A conforming receiver:
- drops nothing it acknowledged while the session lives (P1, P6: Cut 1; P5: Cut 2; P8: made unreachable
by Cut 3);
- holds an ordered frame only while a sequence below it is missing (Cut 1);
- drains held frames in the call that fills the last gap, before it acknowledges (Cut 1).
- So every sequence up to `s` acked means `s` was delivered.
- **And after Cuts 1b and 1c,** an ack can only be credited to the generation that issued the write
(G3, G2).
- **Mixed versions.** A new sender with an old receiver can be wrong only when that receiver strands a
frame (P1, P6). That needs reliable traffic on more than one channel of one session. The C#, TS, Python
and Kotlin socket transports send reliably only on `schema` (`CultNetTransport.cs:2372-2376`,
`rudp.ts:1254-1262`), so they cannot trigger it. Rust sends reliable `media`/`audio` too
(`rudp.rs:2523-2562` `channel_send_options`). The pass-1 Eyes survey found no live session that mixes
those with `schema`: Muninn's media session carries no `schema` traffic, and neither does Sleipnir's HID
stream. The old receiver loses such frames today in any case. Bumping the pin is the fix.
- **Every Odin-bound publisher's receiver is Odin.** Acknowledged means delivered for Idunn health,
presence and catalog publishing only once Odin pins a CultLib with Cuts 1-3.
### 2. What "delivered to the application" means
- **Delivered means the frame left the session's hold and was handed to the transport's application
boundary.** That boundary is:
- C#: enqueued in the socket transport's `_deliveredFrames` (read by `TryReceiveOnce`), or returned in
`CultNetRudpReceiveResult.Delivered` from a bare session;
- Rust: pushed to `delivered_frames` (`rudp.rs:2147`) or the hub's `pending_events`, or handed to the
document server's sink (`cultmesh-rs/src/rudp_document_server.rs:343-360`), which acknowledges only
after the sink accepted;
- TS: the `"frame"` event was emitted (`rudp.ts:1076-1082`);
- Python and Kotlin: the delivered queue.
- **It is not "consumed".** The operator rejected application-driven acknowledgement, and TCP's cumulative
ACK has the same boundary: in the receive buffer, readable, not read.
- A listener or sink that throws or rejects has still been delivered to. The handler-rejection paths from
the stray-packet work end the session, which is the application's answer, not the transport's.
### 3. The public API (Q-A2, ruled: one meaning)
- **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".
- The visible difference: an ordered send behind a missing sequence reads `Pending` until the gap
fills (P3), where today it reads `Acknowledged`.
- Every external caller already means delivered, so none changes code: `cultmesh-rs/src/lib.rs:832`
and `:892`, `tests/rudp_stray_packets.rs:309`, `cultmesh-rs/tests/rudp_document_server.rs:408,415`,
CodexConnector `src/idunn_health.rs:727-745`, Ghostlight
`crates/ghostlight-dungeon/src/idunn_health.rs:1126-1145`.
- **A receipt is resolved once, by its session.** It becomes `Acknowledged` when `A` passes its last
sequence (ordered) or when all its sequences are acked (unordered). It becomes `Invalidated` when its
generation ends first. After that it never changes.
- Mechanism: the receipt carries its sequences, whether it was ordered, and a shared status cell
(`Arc<AtomicU8>`). The session keeps the unresolved cells, at most one per outstanding send, and
writes each once. `reliable_send_status(&receipt)` (`rudp.rs:1931-1936`) reads the cell; its
signature does not change.
- This deletes the session scope (`rudp.rs:135`, `:179`, `:206`, `:283-285`, `:291-293`, `:345`,
`:601`) and needs no history of ended generations. Today a delivered write reads `Invalidated` after
any reset, because the scope rotates (`rudp.rs:291-293`). The pass-1 design needed a frozen snapshot
of one ended epoch and still misreported older ones.
- `PartialEq` on the receipt compares its sequences, which are unique within a session object because
issued sequences survive resets.
- `send_reliable` receipts cover queued packets (P10). The receipt lists every sequence the send
assigned, not only the ones admitted to the wire (`send_many`, `rudp.rs:456-528`, returns only the
admitted ones; `non_expiring_reliable_receipt`, `:305-318`, refuses an empty set).
- `send_reliable` refuses a channel with reliable expiry (P9). The comment at `rudp.rs:1915-1916` says
this lineage has no expiry, which is false since `bac5be88`. Restore the guard it describes.
- New: `CultNetRudpSocketTransportConnection::wait_acknowledged(&receipt, timeout) -> Result<()>`. It is
the ack wait: it pumps receive and resends like `flush_reliable` and returns when the receipt is
`Acknowledged`. It fails at once when the receipt is `Invalidated`, naming the end reason. It replaces
the hand-rolled loops at `cultmesh-rs/src/lib.rs:806-838` and `:870-900`; CodexConnector and
Ghostlight may adopt it at their next bump, and their existing polling stays correct without it.
- **Flush: one wait, and it means every send of this generation is Acknowledged.**
- A flush belongs to the generation it started in. It fails when that generation ends, whatever comes
after. Rust binds it to the scope today (`rudp.rs:2001-2014`) and TS to its transport generation
(`rudp.ts:911-935`). C# (`CultNetTransport.cs:1914-1931`) and Python (`transport.py:1101-1124`) check
only the disconnect reason, which the next Connect clears (`CultNetTransport.cs:1843`, `:2235`;
`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.
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
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 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
Surveyed across `F:\Projects` in pass 1; the rows that moved are re-checked at the revision named. Every
consumer that waits wants delivered, which is now just Acknowledged.
| Consumer | What it waits on today | Change |
|---|---|---|
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `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 |
| Heimdall `src/idunn-rudp-health.ts:55-87` (vendored CultLib `b6b1d9c`, Heimdall `d5acc94`) | the first Ack packet | Heimdall's own cut at its bump: call cultnet-ts's publisher after Cut 5 |
| CodexConnector `idunn_health.rs:727-745`, Ghostlight `idunn_health.rs:1126-1145` | `Acknowledged` | None required. Optional `wait_acknowledged` at their next bump |
| Muninn `main.rs:9446-9458`, Sleipnir `main.rs:880-904` | nothing (send and drop) | Follow-up: fire-and-forget health can be silently lost |
| Kotlin `flushReliable` | no callers | Contract text only |
### 5. The hold buffer and the 4 MiB cap
- **It belongs here**, because the held frame is exactly the state this map names.
- **Mechanism (P4).** Rust caps held ordered frames at 1,024 frames and 4 MiB (`rudp.rs:47-48`, checked
at `:1181-1187`). A refusal ends the session. The default payload limit is 16 MiB (`rudp.rs:33`). With
one lost packet under a large ordered transfer, a legal 5 MiB frame is refused and kills the session.
- The sender's window counts pending packets, not span or bytes (`rudp.rs:910-942`). While one packet
stays lost, acked packets keep freeing slots, so the held data behind the gap grows without limit.
- **Fix: a sender-side flow window, TCP's receive window without a wire field** (Cut 3). A reliable
packet is admitted to the wire only while:
- its sequence is at most 1,023 above the lowest unacked sequence; and
- the payload bytes of sequences above the lowest unacked one stay within 4 MiB, with at least one
packet always admissible.
- Otherwise it waits in the existing queue.
- **What this buys:**
- a conforming sender never makes a Rust receiver hold more than 4 MiB, so the cap stops refusing legal
traffic;
- a conforming sender never gets 1,024 sequences ahead of the receiver's highest, so the Rust far-ahead
refusal (`rudp.rs:46`, checked at `:626-630`) never drops its packets;
- a conforming sender never leaves a sequence 4,096 behind the receiver's highest, so P8 cannot happen.
The receive-history window stays 4,096 in every runtime (`CultNetTransport.cs:884`, `rudp.rs:35`,
`rudp.ts:20`, `transport.py:404`, `CultMesh.kt:2625`).
- **What is kept.** The cap and the far-ahead refusal stay. They protect against hostile peers.
- **Rejected:** raising the cap to the payload limit or above (16-64 MiB per hostile session instead of
4); lowering the ordered payload limit to 4 MiB (an API regression for large snapshots).
- **Cost.** Normal flow never has a span beyond the 32-packet window, so the new limits bind only while a
packet is lost.
- **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 and FORWARD-TSN (P2; Q-A3 B, ruled)
- **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
The ack cuts build on these behaviours and do not alter them:
- A refusing session ends, and its goodbye is built after the reset (`EndRefused`
`CultNetTransport.cs:1315-1319`, `end_refused_session` `rudp.rs:364-367`). The refused sequence is never
acked, so a refusal never becomes a false Acknowledged.
- Issued sequences survive a reset in every runtime (`rudp.rs:341-343`, `CultNetTransport.cs:1292-1308`,
`rudp.ts:214-227`, `transport.py:458-471`). Cut 1c relies on it: a client's second Connect always
carries a new sequence.
- One Accept per generation, and a repeated Connect answered with the Accept still owed, or an Ack. Cut 1c
keeps the answer and moves the decision of what repeats into the session.
- An ended session fails its flush. Cut 1b generalises the TS and Rust binding to the generation into all
four runtimes.
- `5f1e883d` made a peer's Disconnect end Rust receipts (`rudp.rs:596-615`) and removed the
transport-level Pending→Invalidated override. Cut 1b makes the other ending paths agree with it.
- The TS operation service's expiry and handler rejection are above the transport and do not change.
### 8. Session generation: why it is two cuts, and how a new one is recognised
- **Why not inside Cut 1.** Cut 1 is a receiver change with a deletion line: one watermark replaces five
per-channel state machines. Its falsifiers are P1, P1b and P6. The generation work touches the ending
paths, the handshake, flush and receipts, and its falsifiers are G1-G3. Both touch `ResetPeerState` and
the Accept path, which is a merge cost, not a shared owner. Keeping them apart lets Soul falsify each
against its own probes.
- **Why two cuts.** The ending rule (Cut 1b) is ruled and needs no mechanism choice: every ending path
drops what it owed. How a new generation is recognised (Cut 1c) needs one (Q-A6). The two are separable
because 1b does not forget peer state: the peer may not know the session ended, and forgetting what was
received would let its retransmits be delivered twice.
**Correction (Cut 1/1b Soul, 2026-09-30): they are separable as specs, not as merges.** 1b drops owed
writes, which leaves holes the sender will never fill; the receiver keeps `R` across the ending, so if the
next handshake does not reset it, every later ordered frame is held and acked, and flush succeeds while
nothing is delivered. Only 1c's reset on a non-repeated handshake removes that. **1b merges only together
with 1c.** Soul also found `R` seeded by the first reliable packet rather than the handshake; ruled:
`R` is seeded only by the handshake, and reliable data before the Accept is neither acked nor delivered.
- **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 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
generation. Its retransmits do; a client's second `connect()` cannot, because issued sequences
survive resets; a restarted process almost never does, because it drew a fresh random initial
sequence.
- A stale duplicate of the accepted Connect, delayed in the network, still repeats: same sequence.
- Anything else ends the current generation and starts a new one, at both ends: the client forgets peer
state when it sends a Connect on a session that had a peer, and the server when it accepts a Connect
that does not repeat.
- A client honours an Accept only while its Connect is pending, and only if the Accept's ack field names
that Connect. A stale Accept from an earlier generation cannot seed `R`.
- The initial sequence is drawn from [1, 2^31), which leaves at least 2^31 sequences before the
exhaustion error (`rudp.rs:944-962`) in every runtime. An explicit `initial_sequence` still works, for
tests.
- The Connect payload stays application evidence (the provider layer's `clientSessionId`,
`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<CultNetRudpReliableSendReceipt>` | `packet_sequences()` | `reliable_send_status(&receipt) -> CultNetRudpReliableSendStatus` | `wait_acknowledged(&receipt, timeout) -> Result<()>` |
| C# (6a) | `CultNetRudpSendReceipt SendReliable(string channelId, byte[] payload)`; `SendSchema(...)` and `SendSchemaMessage<T>(...)` return the receipt instead of `void` (source-compatible) | sealed class, `IReadOnlyList<uint> 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<void>`, 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<Long>` | `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. **Corrected 2026-09-30:** Node does not report send failures on the socket's `error` event. With a callback they go to the callback; without one, libuv drops them silently (Node 24). Only port 0 throws synchronously. Cut D routes every send through `sendRudpDatagram`. | 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, on its own branch (`hands/ack-cut<N>`) 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 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:**
- the cross-runtime reorder, drop and fragment proofs in the TS interop harness
(`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: the existing fixture's bytes never change. Cut F adds one fixture.
### 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:**
- the per-channel `next` map and its resets: `CultNetTransport.cs:918`, `:1305`; `rudp.rs:196`, `:223`,
`:354`; `rudp.ts:160`, `:223`; `transport.py:424`, `:468`; `CultMesh.kt:2643`;
- `expectedSequenceIfUninitialized`: `CultNetTransport.cs:1151-1153`, `rudp.rs:540-543` (used at
`:679`), `rudp.ts:375-377` (used at `:450`), `transport.py:595-599`, `CultMesh.kt:2728`;
- `DeliverOrdered`, `DrainOrdered` and `SkipReceivedNonChannelSequences`: `CultNetTransport.cs:1628-1700`,
`rudp.rs:1138-1246`, `rudp.ts:737-800`, `transport.py:868-920`, `CultMesh.kt:2939-2978`;
- `PendingOrderedFrame.NextSequence` (`rudp.rs:160-163` and equivalents).
- **Adds:**
- `R`, seeded in `AcceptConnect` (`CultNetTransport.cs:1012`, `rudp.rs:383`, `rudp.ts:242`,
`transport.py:485`, `CultMesh.kt:2667`) and on Accept receipt (`CultNetTransport.cs:1155`,
`rudp.rs:544`, `rudp.ts:379`, `transport.py:601`, `CultMesh.kt:2730`);
- `R` advances in `RememberReceived` (`CultNetTransport.cs:1518`, `rudp.rs:973`, `rudp.ts:619`,
`transport.py:809`, `CultMesh.kt:2891`) and resets in `ResetPeerState`;
- a reassembled ordered frame is delivered at once when its first sequence is at most `R + 1` and its
channel holds nothing below it. Otherwise it is held. The caps apply only to what is held: the
prototype held everything and so refused a 5 MiB frame with no gap;
- after every reliable receipt (data, fragment, duplicate, Accept), drain every channel's held frames
whose first sequence is at most `R + 1`, in sequence order;
- Rust only: the 64-channel cap (`rudp.rs:42`) counts channels with held frames.
- **Tests changed:** `packages/cultnet-rs/tests/cultnet.rs:1000` (consistent handshake) and `:2655`
(hold frames behind a gap). The equivalents in the other runtimes are whatever the change breaks. Each
must be read before it is edited, because a test that encodes `min(highest+1, frame)` encodes P6.
- **Verification:**
- P1, P1b and P6 pass in every runtime;
- the full suites: `cargo test` in `cultnet-rs` and `cultmesh-rs`, `dotnet test
tests/GameCult.Networking.Tests`, `npm test` in `cultnet-ts`, the Python suite;
- the TS interop harness across runtimes;
- negative grep: `rg -n "orderedNextSequence|ordered_next_sequence|expectedSequenceIfUninitialized|expected_sequence_if_uninitialized|SkipReceivedNonChannel|skip_received_non_channel" src packages`
returns nothing;
- 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 — 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
started before a timeout or disconnect fails even after a later `connect()` succeeds.
- **The owner.** Each session gets a generation counter and one private primitive that ends the current
generation: not connected; pending and queued dropped; generation advanced. In Rust it also invalidates
the generation's receipts (by the scope today; Cut 4 moves that into the receipts). It does not touch
what was received from the peer (§8).
- **Deletes first:**
- Rust: the inline clearing in the Disconnect branch (`rudp.rs:596-615`) and the scope rotation in
`reset_peer_state` (`:345`), both replaced by the primitive; the receipt doc that says local endings
carry writes (`rudp.rs:128-133`); the flush's scope capture (`:2001-2014`), replaced by the generation;
- TS: the transport's `#generation`, `#endedGeneration`, `#endGeneration` (`rudp.ts:826-831`,
`:937-940`) and their uses in `connect` (`:895`), `flush` (`:913-920`), `checkTimeout` (`:949`),
`#endRefusedSession` (`:976`), the server Connect path (`:1037-1046`) and the disconnect result
(`:1084`). `#endedReason` stays, as the report of why;
- C# and Python: flush's reliance on the disconnect reason (`CultNetTransport.cs:1924-1929`,
`transport.py:1110-1116`). The reason stays, as a report.
- **Adds:**
- the primitive, called from every ending path:
- peer Disconnect: `CultNetTransport.cs:1190-1199`, `rudp.rs:596-615`, `rudp.ts:406-414`,
`transport.py:619-625`;
- local disconnect: `CreateDisconnect` `CultNetTransport.cs:1324-1328`, `create_disconnect`
`rudp.rs:771-781`, `createDisconnect` `rudp.ts:508-515`, `create_disconnect` `transport.py:695-697`;
- timeout, when it fires: `CultNetTransport.cs:1333-1348`, `rudp.rs:783-796`, `rudp.ts:517-526`,
`transport.py:699-706`;
- refusal, through `ResetPeerState` (`CultNetTransport.cs:1292-1308`, `rudp.rs:344-358`,
`rudp.ts:214-227`, `transport.py:458-471`);
- a read-only generation on each session, which each transport's flush captures at start and fails on
when it changes (`CultNetTransport.cs:1914-1945`, `rudp.rs:1992-2040`, `rudp.ts:911-935`,
`transport.py:1101-1124`).
- **Hubs.** The Rust hub and the C# listener already discard a peer's session at its end, so its owed
writes die with it. No change.
- **Tests changed:** `tests/rudp_stray_packets.rs:638-690` keeps passing unchanged (peer Disconnect already
obeys the rule). Tests that expect a write to survive a local disconnect or timeout encode the old rule;
read each before editing.
- **Verification:**
- G3 in every runtime: after every ending path, owed 0 and nothing delivered to the next session;
- G3R: the receipt reads `Invalidated` after `disconnect()`, and stays so after reconnecting;
- the flush test above in every runtime;
- negative: `rg -n "endedGeneration|#endGeneration" packages/cultnet-ts/src` returns nothing; a TS
reconnect from inside a `disconnect` listener still cannot make an ended flush succeed (the case the
TS generation pair was written for, `rudp.ts:826-829`);
- 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 — ready after Cut 1b (Q-A6 A, ruled)
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`).
- **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 (`: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.
- **Adds:**
- 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 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 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 (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`;
- 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 −90 and +85 across four runtimes and the three servers. Two CultLib production pins go.
**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:**
- eviction picks the stalest set that holds no reliable fragment (`rudp.rs:1027-1047`,
`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~~ (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 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
`CultNetTransport.cs:1439-1490`, `rudp.rs:910-942`, `rudp.ts:586-600`, `transport.py:776-796`,
`CultMesh.kt:2856-2872`) admits a packet only while:
- the window has a slot (as today);
- 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. (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.
- **Verification:**
- P8: `g` is delivered;
- P4: the 5 MiB frame is delivered after the gap fills, and the session is never refused;
- the existing large-snapshot tests (`c398bad1`) and the fragment interop stay green;
- the Rust benchmark or media probe shows no throughput change on a lossless link.
- **Ledger.** About +25 per runtime, and nothing removed.
### 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
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. (Corrected 2026-09-30: Node reports send failures only to a send
callback, never on the socket's `error` event; see the table above.)
- **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:**
- 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`): 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 `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.
- **Changes:** `cultmesh-rs/src/lib.rs:832` and `:892` become `wait_acknowledged`; the doc at `:869` says
delivered to the document server's sink.
- **Verification:**
- the new tests pass;
- `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 +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
- **Changes:**
- `idunn-odin-presence-publisher.ts:89-215` and `signed-daemon-health.ts:104-162` (with its helpers
`:244-252`, `:273-389`, except the exported `parseEndpoint` `:254-271`) send through
`CultNetRudpSocketTransportConnection`: `connectAndWait`, `send("schema")`, `flush`, `close`. Their
hand-rolled session loops are deleted. The presence publisher keeps rejecting a `cultnet.error.v0`
response, from the transport's `frame` events during the flush.
- `connectAndWait(payload, timeoutMs)` on the TS socket transport, matching C# `ConnectAndWait`
(`CultNetTransport.cs:1859-1880`). It is `cultmesh-ts/src/index.ts:6603-6650` `waitForRudpConnected` moved
down; `createRudpPeer` (`index.ts:6220-6245`) uses it and the cultmesh-ts copy is deleted.
- Flush contract text in all runtimes: "every reliable frame sent in this session has been delivered to
the peer's application".
- `docs/cultnet-transport-parity.md:236-240` (the control-packet guard paragraph) is replaced by the
watermark rule, and `:261-272` gains: Acknowledged means delivered; a write belongs to the session it
was issued in; how a repeated Connect is recognised (after Cut 1c); the receive window (after Cut 3).
- **Verification:**
- `runtime-presence-health.test.ts` and `signed-daemon-health.test.ts` stay green;
- a new test drops the first datagram and still publishes;
- a new test publishes a payload over 32 packets whole;
- 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
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<T>` (`: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-<name>:*` built from `<name>.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 <CultLib> <rev> 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 | ~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 |
| 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, 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
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.
- **Q-A2. One flush, or a "received" flush beside it?** One meaning: Acknowledged means delivered.
- **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-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-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-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 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 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. 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`).
- **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.
- **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.
**Follow-up (media FEC Cut 3 Hands, 2026-09-30):** `channel_send_options` in `rudp.rs` has no test for the
`"schema"` and `"latest"` channel arms. Deleting either falls to the default and every test stays green. Pin their send
flags on the wire the way the media arm is now pinned (raw peer socket), in whichever cut next touches channel
profiles.
**Rulings after the Cut 1/1b/1c Soul pass (Self, 2026-09-30):**
- **Stale data after a reconnect.** A reliable sequence at or before the generation's seeded `R` (serial arithmetic
within the receive window) is a duplicate: acknowledged, not delivered. This is what §6/§8 already promised.
- **Stale Connect (RFC 5961's challenge ACK).** A Connect whose sequence precedes the current generation's Connect
within the receive window is stale: it is answered with an Ack naming the current generation and does not reset.
A client whose Connect gets no Accept before its connect attempt times out starts a fresh attempt with a newly
drawn initial sequence, so a restarted client whose random sequence lands in that window still connects.
- **Server mode.** A Connect from a different endpoint is never a repeat; the session predicate stays sequence-only.
(Hands' deviation 5 was rejected: pinned clients restarting on a new port were locked out.)
- **The random default belongs to the session.** A session draws its own initial sequence when none is set, so one
options object reused across sessions or reconnects never repeats a sequence.
- **Connect attempts (Self, 2026-09-30, after the Cut 1c fix-batch Soul pass).** While a Connect awaits its Accept, only an
Accept the client honours retires it; an Ack never does (an Ack naming the pending Connect had wedged the client for
good). A fresh attempt after the connect-attempt timeout uses exactly the abandoned sequence + (receive window − 1),
not a random draw: that keeps a late copy of the abandoned Connect inside the new one's stale window while escaping the
server's stale window (unless the server sits exactly there, in which case the next attempt escapes). Only a client's
first Connect draws randomly.
- **Recorded divergence (not this campaign's to fix yet).** Only Rust has a receiver receive-ahead window (1,024). TS,
C# and Python acknowledge a far-ahead reliable frame by name and hold it behind an ordered gap indefinitely, so a
stranded ordered channel is silent loss there and "still owed" in Rust. Map a receive window for the three runtimes.
- **Residuals after the final Cut 1c Soul pass (recorded 2026-09-30, low, not fixed):**
- A late copy of a Connect from two or more attempts back (reordered > ~3 s) still restarts the server; the +window−1
rule keeps only the immediately previous attempt stale. Rust then times out and reconnects; TS/C#/Python hit the
receive-window divergence above (silent loss behind the gap).
- A client pinning `initial_sequence` that restarts while its old data is still in flight after the restarted Connect
can be matched to the old session's resent Accept (byte-identical; the client cannot tell). Pinning forfeits restart
detection; the random default closes it. Document on the option.
- Repeated failed attempts walk the sequence up 4095 per 3 s and clamp at `u32::MAX−1` after ~18-27 days on one session;
make exhaustion a hard error the caller sees (the clamp removal mutant survives).
- **Cuts 3 and D Soul pass (Self, 2026-09-30).** Cut 3: the 4 MiB bound counts every byte sent above the lowest
unacknowledged sequence, acked or not (the spec's wording; the first implementation counted only pending packets).
Cut D: a send failure is either permanent (the datagram can never be sent as built, e.g. `EMSGSIZE`) or transient;
a permanent failure is returned from a caller-directed send and, inside a poll, ends only that peer's session with a
typed reason; transient failures stay counted losses. Windows `ConnectionResetError` on receive is idle, never fatal,
in every server. Cut D's negative grep `send_packet(...)?;` is obsolete: the `?` now propagates encode errors only.
- **Cut 3 merged at `1210fdc7` (2026-09-30).**
- Batch 2 fixed Rust `send`, which checked the window before purging expired packets. It also added a
new-generation test in all four runtimes.
- Soul pass on batch 2: nothing found. 18 mutants were killed. Interop was 27/27, run in `ack1d-interop:2`,
whose Linux wrappers let the Windows-only Kotlin build run.
- Residual: `packages/cultmesh-rs/Cargo.lock` is stale.
- Also recorded: `packages/cultmesh-kotlin/build.ps1` is Windows-only, so Kotlin interop runs off Windows only
through an image with wrappers.
- P2 (the expiry hole) stays with Cut F.
- **Cut D batch 2, Soul pass 2: fix first.**
- cultnet-ts emits transient send failures as `"error"`, which crashes processes with no listener. Section 10's
claim that "Node reports send failures on the socket's error event" is false on Node 24 without a callback:
they are dropped silently.
- C# stale peer objects write into the replacing session.
- cultmesh-py dies on a non-ValueError per-peer failure.
- Six guards are unpinned.
- Batch 3 is in Hands.
- **K1 gains:** Kotlin's idle resend loop (`CultMesh.kt:4033-4036`) is one `try`, so one peer's permanent failure
stops resends for every later peer. That is in addition to a refused packet not ending that peer's session.
- **Recorded (liveness, not fixed):** a lowest reliable packet lost forever stalls the sender 1,023 sequences ahead
while the peer stays alive; session timeout doesn't end it. TCP ends such a connection after a retransmission
limit (R2). Decide with Cut F (abandonment) whether a reliable packet outstanding past a bound ends the session.