133 KiB
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, andcreate_ackreports 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) addsacknowledgement_withheldto 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
0x10so 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
mediachannel). 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
mainate9469cb3. Every transport file is byte-identical to3bf1c0cthere (git diff 3bf1c0c e9469cb3 -- src packagesis empty), so everyfile:linebelow is against both unless it names another revision.- Odin
origin/main(5c37860) pins3bf1c0ce(crates/odin-core/Cargo.toml:14-17). A stale local checkout at379b826still showsa8aeddac; read Odin's pin fromorigin/main. - Pass 3 probes:
3e2c7efb(FN) andc6f1012c(CAP) on the Yggdrasil mirrorprobe3(§ Probe evidence, pass 3).
- Odin
-
Base of every cut.
mainate9469cb3or later. -
Scope. The RUDP session and socket transports in all five runtimes: C#
src/GameCult.Networking/CultNetTransport.cs, Rustpackages/cultnet-rs/src/rudp.rs(pluspackages/cultmesh-rs), TypeScriptpackages/cultnet-ts/src/rudp.ts(plus the two hand-rolled publishers andcultmesh-ts's document server), Pythonpackages/cultnet-py/src/cultnet_py/transport.py(pluscultmesh-py's server), and Kotlinpackages/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, reliableuon another channel (lost),o2,o3. Whenuarrives, onlyu's channel is drained.o2ando3are 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,C2on a new channel, thenXon another.Xarrives, thenC2, thenC1. The channel initialises itsnextfrommin(highest+1, frame), soC2is delivered first andC1is dropped as old (frame.sequence < next). It was acknowledged. - Both reproduce at
3bf1c0cin C#, Rust, TS and Python (pass 2), and did atd85877a7, Odin's old pina8aeddacand Muninn's pinc2a9a6e5(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 pina8aeddac.bf8fc826: all four at Muninn's pinc2a9a6e5.db7d2886: the prototype, the Rust receiver rebuilt on one watermark plus sender-derived receipt states (about +130/−125 inrudp.rs). ItsReceived/Deliveredreceipt states are superseded by Q-A2; its receiver is what Cut 1 builds.
Pass 2 (base 3bf1c0c; mirror CultLib-imag-ack2):
a383e27d, then1519a20a(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, pluspackages/cultnet-rs/tests/probe_gen.rs,packages/cultnet-ts/test/probe-gen.test.ts,packages/cultnet-py/tests/probe_gen.pyandtests/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
acceptConnecton the connected session, which is what the socket transport does for a same-endpoint Connect (rudp.ts:1034-1049calls it without a reset, andrudp.ts:249-251answers it as a repeat). G2 and G3 are session-level: the socket transports'connect()iscreate_connectand 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.csandpackages/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 Kotlineclipse-temurin:21-jdkwithkotlin-compiler-2.2.21.zipfetched 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.rs51 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-rsdid 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
Rhas been received since the peer state was last reset. - A held ordered frame whose first sequence is at most
R + 1is delivered, in sequence order, in the same call that advancedR. Ris seeded by the handshake.AcceptConnectseeds it with the Connect's sequence, and receiving the Accept seeds it with the Accept's sequence. A session connected without a handshake (Rustassume_connected,rudp.rs:243, test-only today) seeds it from its first reliable packet.- It advances in
RememberReceivedand resets with the rest of the peer state (ResetPeerState).
- Every reliable sequence up to
- 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'sAbandonbranch 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 whenAreaches 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.
- What was learned from the peer (
- 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
nextmap 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). expectedSequenceIfUninitializedand its equivalents are deleted (CultNetTransport.cs:1151,rudp.rs:540,rudp.ts:375,transport.py:595,CultMesh.kt:2728).PendingOrderedFrame.NextSequenceis deleted (rudp.rs:160-163and equivalents).OutstandingReliablePacketCountstays, 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:345and: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,#endedGenerationand#endGenerationin the socket transport (rudp.ts:830-831,:937-940) are no longer owners; the session's generation replaces them. DisconnectReason/disconnect_reason/#endedReasonare report-only: why the last generation ended. They no longer gate flush.
- The per-channel
- Forbidden writers.
- No path delivers an ordered frame except the drain after
Radvances. - 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
Abandonbranch (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# listenerCultNetTransport.cs:2670-2688), not byConnected(C#:2226-2236, Pythontransport.py:1047-1053, TSrudp.ts:249-251,:1031-1049), not by server key or payload (cultmesh-rs document serverrudp_document_server.rs:413-427, cultmesh-ts document serverindex.ts:5794-5802), and not by resetting on every Connect (Rust server moderudp.rs:2105-2116, cultmesh-py serverserver.py:225-240).
- No path delivers an ordered frame except the drain after
- Shared paths. Accept, reliable data, fragments, duplicates and retransmits all advance
RthroughRememberReceivedand then call the drain. Direct sends, fragmented sends, queued promotion and resends all feed the pending and queued sets thatAreads. Every ending path goes through the one generation primitive. - Deletion line.
DeliverOrdered,DrainOrderedandSkipReceivedNonChannelSequencesand 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
sacked meansswas 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 reliablemedia/audiotoo (rudp.rs:2523-2562channel_send_options). The pass-1 Eyes survey found no live session that mixes those withschema: Muninn's media session carries noschematraffic, 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 byTryReceiveOnce), or returned inCultNetRudpReceiveResult.Deliveredfrom a bare session; - Rust: pushed to
delivered_frames(rudp.rs:2147) or the hub'spending_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.
- C#: enqueued in the socket transport's
- 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):
CultNetRudpReliableSendStatuskeeps its three variants,Pending,Acknowledged,Invalidated(rudp.rs:146-150). No variant is added, removed or renamed.Acknowledgedchanges meaning from "every packet was acked" to "delivered".- The visible difference: an ordered send behind a missing sequence reads
Pendinguntil the gap fills (P3), where today it readsAcknowledged. - Every external caller already means delivered, so none changes code:
cultmesh-rs/src/lib.rs:832and:892,tests/rudp_stray_packets.rs:309,cultmesh-rs/tests/rudp_document_server.rs:408,415, CodexConnectorsrc/idunn_health.rs:727-745, Ghostlightcrates/ghostlight-dungeon/src/idunn_health.rs:1126-1145.
- The visible difference: an ordered send behind a missing sequence reads
- A receipt is resolved once, by its session. It becomes
AcknowledgedwhenApasses its last sequence (ordered) or when all its sequences are acked (unordered). It becomesInvalidatedwhen 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 readsInvalidatedafter 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. PartialEqon the receipt compares its sequences, which are unique within a session object because issued sequences survive resets.
- Mechanism: the receipt carries its sequences, whether it was ordered, and a shared status cell
(
send_reliablereceipts 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_reliablerefuses a channel with reliable expiry (P9). The comment atrudp.rs:1915-1916says this lineage has no expiry, which is false sincebac5be88. Restore the guard it describes.- New:
CultNetRudpSocketTransportConnection::wait_acknowledged(&receipt, timeout) -> Result<()>. It is the ack wait: it pumps receive and resends likeflush_reliableand returns when the receipt isAcknowledged. It fails at once when the receipt isInvalidated, naming the end reason. It replaces the hand-rolled loops atcultmesh-rs/src/lib.rs:806-838and: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,
Ais 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). Thereflush_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, TSflush, Pythonflush_reliableand KotlinflushReliable(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).
- 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 (
- Defaults. Plain flush. No parameter. No second flush.
- Compatibility. No signature changes in any runtime except additions (
wait_acknowledged; TSconnectAndWait, §4; the receipts of §9, where C# and KotlinSendSchema*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.
- The sender's window counts pending packets, not span or bytes (
- 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 atpackages/cultnet-py/README.md:103-111is false,transport.pyhas none). An expired packet's sequence never reaches the receiver,Rstops below it, and every ordered frame after it on the session is held until the session ends. - Who expires. Every Rust
mediasender 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:1303and through the profile at:1548-1549). Media frames fragment. Muninn's reliableaudioalso expires;hands/media-fec-cut3deletes 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.Pointover 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
Rowns ordered delivery for every channel (Cut 1), so advancingRand 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):
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_resendsemits a fresh notice for each due entry (built from the current ack state, not stored), once per resend delay. AfterRUDP_ABANDON_NOTICE_SENDS = 16sends without an ack the entry leaves the set and the session records the lowest such sequence asfirst_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
Ris held below it); a stranded one does not (an old receiver has noRto hold, and counting it would stop the session after 1,023 more sequences). outstanding_reliable_packet_countcounts abandoned entries, so a flush waits for their notices.- The session-ending primitive (Cut 1b) and
reset_peer_stateclear the abandoned set andfirst_stranded: they belong to the generation.
Receiver (all five runtimes).
receivehandlesAbandonafter 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_indexfor any fragment it holds, forfragment_countsequences), whether or not the sequence was received: that frame can never complete; - if the sequence was not received, it is remembered through
RememberReceived, which advancesR(Cut 1), and held frames up toR + 1drain intodelivered; replyiscreate_ack_for_received(sequence), whether or not anything changed (the out-of-date case still acks).
- outside
- Transports send the reply as they already do, and skip their own ack for an
Abandonpacket (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 callsRememberReceived; 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
mediaframe (soul_probe_expiring_reliable_frames_kill_receiver,packages/cultnet-rs/tests/cultnet.rs:3192at probe3be97b34). 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_strandedwhere pass 2 readfirst_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 (
EndRefusedCultNetTransport.cs:1315-1319,end_refused_sessionrudp.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.
5f1e883dmade 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
ResetPeerStateand 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
Racross 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 foundRseeded by the first reliable packet rather than the handshake; ruled:Ris 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 explicitinitial_sequencestill 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.
- A Connect repeats exactly when it carries the sequence of the Connect that started the current
generation. Its retransmits do; a client's second
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
Areaches 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_manyreturns 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.ApplyAcknowledgementsonly 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 orcultmesh-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.Statusis the idiom there, and it is what Rust's cell already is underneath. Rust keepsreliable_send_statusbecause its signature is public and used (Cut 4, Unchanged). SendReliablerefuses a channel whose send options are not reliable (as Rust'ssend_reliabledoes,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;
WaitAcknowledgedon 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_oncebegins withself.maintain()?(cultmesh-rs/src/rudp_document_server.rs:264).maintainsends every peer's due resends and returns on the first failedsend_to(:392-398, throughsend_packet:628-632). Every per-peer send inpoll_oncedoes 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 pastmaintainagain 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 anypoll_onceerror, 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), sosend_toalso fails withWouldBlockwhen 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.
- So one peer whose datagrams cannot be sent fails every poll for every peer. Its resends come due every
50 ms (
- 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_fromfailing with anything butWouldBlockorConnectionReset(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.
WouldBlockis transient and not the peer's fault; ending on it would drop healthy peers under load.
- Only failures that are not about one peer stay errors:
- 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
nextmap 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,DrainOrderedandSkipReceivedNonChannelSequences: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-163and equivalents).
- the per-channel
- Adds:
R, seeded inAcceptConnect(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);Radvances inRememberReceived(CultNetTransport.cs:1518,rudp.rs:973,rudp.ts:619,transport.py:809,CultMesh.kt:2891) and resets inResetPeerState;- a reassembled ordered frame is delivered at once when its first sequence is at most
R + 1and 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 encodesmin(highest+1, frame)encodes P6. - Verification:
- P1, P1b and P6 pass in every runtime;
- the full suites:
cargo testincultnet-rsandcultmesh-rs,dotnet test tests/GameCult.Networking.Tests,npm testincultnet-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 packagesreturns 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 inreset_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 inconnect(:895),flush(:913-920),checkTimeout(:949),#endRefusedSession(:976), the server Connect path (:1037-1046) and the disconnect result (:1084).#endedReasonstays, 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.
- Rust: the inline clearing in the Disconnect branch (
- 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:
CreateDisconnectCultNetTransport.cs:1324-1328,create_disconnectrudp.rs:771-781,createDisconnectrudp.ts:508-515,create_disconnecttransport.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);
- peer Disconnect:
- 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).
- the primitive, called from every ending path:
- 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-690keeps 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
Invalidatedafterdisconnect(), and stays so after reconnecting; - the flush test above in every runtime;
- negative:
rg -n "endedGeneration|#endGeneration" packages/cultnet-ts/srcreturns nothing; a TS reconnect from inside adisconnectlistener 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-cut1cfrommainafter 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).
- 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
- 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.Connectedbranch (CultNetTransport.cs:2226-2236); the C# listener's payload comparison (:2670-2688); TS'smovedEndpoint/repeatedcomputation (rudp.ts:1011-1049) and the session'sconnectedshortcut (rudp.ts:249-251); Python'sconnectedbranch (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-415that asks a fresh client to choose a fresh id); the cultmesh-ts document server'sconnectPayloadcomparison 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.
- the transport-level repeat decisions: Rust server mode's reset on every Connect (
- 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.
AcceptConnectanswers a repeat asAnswerRepeatedConnectdoes 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).AnswerRepeatedConnectstops being public; a public predicateConnectRepeats(packet)/connect_repeats/connectRepeatsserves servers that keep one session per peer and must report the old one ended (the Rust hubrudp.rs:1648-1690, the C# listener:2690-2710, the cultmesh-rs document server). Servers that only callAcceptConnect(TS operation serviceoperation-service.ts:167-175, cultmesh-ts document server, cultmesh-py server, server-mode transports) need nothing else: the session decides; CreateConnecton 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)(rand0.9 is already a dependency,packages/cultnet-rs/Cargo.toml), inCultNetRudpSessionOptions::default(rudp.rs:108),CultNetRudpSocketTransportOptions::client()(:1310) and::server()(:1329),CultNetRudpServerHubOptions::new()(:1377), andCultMeshRudpSocketOptions::default(cultnet-rs/src/cultmesh.rs:259); - C#:
RandomNumberGenerator.GetInt32(1, int.MaxValue)as the property initializer atCultNetTransport.cs:773,:850,:2404(the listener's 100),CultNetRudpSchemaServer.cs:48(a second 100) andsrc/GameCult.Mesh/CultMesh.cs:547; - TypeScript:
crypto.getRandomValuesin the session constructor'soptions.initialSequence ?? …(rudp.ts:172);cultmesh-ts's optionalinitialSequence(index.ts:4336) already falls through to it; - Python:
secrets.randbelow(2**31 - 1) + 1as adefault_factoryattransport.py:298,:323, and as the default ofclient.py:396andcultmesh-py/src/cultmesh_py/facade.py:160,:194(keyword defaults becomeNonemeaning "draw one", since a Python default is evaluated once); - Kotlin: in Cut K2;
- Rust:
- 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.
- 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.
- Tests changed (they encode payload identity or a fixed default):
packages/cultnet-rs/tests/rudp_server_hub.rs:171-240callsconnect()again with the same payload and expects no replacement; under this rule that is a new generation. Its retransmit half stays covered byrudp_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;:700assertsPendingReliableSequences == {100}against the listener's default;- asserts that may rely on a default Connect sequence of 1 (read each; pin
1in 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 packagesreturns 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 StreamPixelsa49f7d8) 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.
- eviction picks the stalest set that holds no reliable fragment (
- Verification: P5 delivers the reliable frame.
d3bc6e66's test (lossy fragment sets keep being evicted) stays green.fragment_sets_evictedstill counts evictions. - Ledger. About −3 and +12 per runtime.
- Pass 3 correction (from the Cut 2 Soul pass on
hands/ack-cut2c5026aa1). The third bullet above is false for an expiring sender: the default Rustmediachannel 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'ssoul_probe_expiring_reliable_frames_kill_receiver(packages/cultnet-rs/tests/cultnet.rs:3192at3be97b34) 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 (
d3bc6e66lineage;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, frommain; 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'sset_max_pending_fragment_sets,rudp.rs:328-340), a per-set last-touched ordinal, eviction of the stalest set inReassemble(:1556),FragmentSetsEvictedbesideOutstandingReliablePacketCount(:993), and Cut 2's rule (a reliable set is not evicted; if every set is reliable the session is refused throughEndRefused), gated as in Cut F. - Verification: the two tests;
GameCult.Networking.Testsgreen; 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/PromoteQueuedReliableatCultNetTransport.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:
gis 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.
- P8:
- 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-cutDfrommainate9469cb3or 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_oncenever returnsErr; peer Y connects, publishes, is acked and gets a snapshot response;maintainreturnsOk; the failure count rises; X's session ends by idle timeout, not by the failure. Also:recv_fromreturningConnectionResetisIdle; - Rust hub:
poll_resendsandreceive_event_oncereturnOkand keep serving Y; - C# listener:
PollResendsreaches Y after X throws;TryReceiveOnceserves Y; - cultmesh-py server: the RUDP thread survives an
OSErrorfromsendtoand keeps serving Y. - The failure seam. The Rust servers own a
UdpSocketdirectly, 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 syntheticio::Errorinsend_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 patchessocket.sendtoin 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.
- cultmesh-rs document server:
- Changes:
- cultmesh-rs
rudp_document_server.rs:send_packet(:628-632) keeps encode errors as errors and turns asend_tofailure into a counted loss; every per-peer call site drops its?on the send (:335,:373,:397,:451,:587,:603); asend_failures()accessor besidepackets_dropped()(:246-248);recv_fromtreatsConnectionResetlikeWouldBlock(: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 inCultNetTransportStatsas a newsend_failuresfield;poll_resends(:1762-1777) and the receive path (:1644,:1673,:1708,:1711,:1714,:1759) stop propagating it; - C#
CultNetRudpSocketTransportServer:SendPacket(:2829-2834) catchesSocketException, counts it inCultNetTransportStats.SendFailures, and returns;PollResends(:2796-2805) andTryReceiveOnce'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) catchesOSErrorand counts it.
- cultmesh-rs
- 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'serrorevent; 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.Testsandcultmesh-pysuites; - negative:
rg -n "send_packet\(.*\)\?;" packages/cultmesh-rs/src/rudp_document_server.rsreturns nothing (the pattern matches only a propagated send; encode errors propagate insidesend_packet); - operator: an Odin build on this CultLib keeps serving presence while one peer's address is unroutable.
- the four tests; the full
- 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-cutFfromhands/ack-cut2rebased onmainafter Cuts 1 and 3 merge. Cut 1 gives the receiverRand 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 tomaintogether (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:
Abandonencodes 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,o2marked 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
mediawith expiry is lost, then ordereds1,s2onschema; after the notice both are delivered andflush_reliablesucceeds; - 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:3192at3be97b34): 100 defaultmediaframes, 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).
- the golden:
- Deletes first:
- Rust: the plain drop in
purge_expired_reliable(rudp.rs:898-908; theretaincalls 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.
- Rust: the plain drop in
- Adds, receiver (C#, Rust, TS, Python):
- the
Abandonpacket type and its codec mapping: Rust enumrudp.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) andCultNetRudpPacketCodecEncodeFlagsCultNetTransport.cs:697,Decode:676-690(itsEnum.IsDefinedcheck runs on the wire byte, which stays 4); TS unionrudp.ts:26and the codec at:1264-1320; Python enumtransport.py:250, code table:342, codec:1175-1240; - the
Abandonbranch inreceive, 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),RememberReceivedand the Cut 1 drain, andreply = 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
Abandonpacket 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).
- the
- Adds, capability (Q-A9 B; all four runtimes):
ConnectandAcceptcarry 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) andaccept_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-842as 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 = 16besideDEFAULT_MEDIA_RELIABLE_EXPIRE_AFTER_MS(rudp.rs:39);- the abandoned set and
first_strandedon the session (fields besidepending_reliable,queued_reliable,rudp.rs:193-195); purge_expired_reliablemoves 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-942as Cut 3 leaves them) includes the set and excludesfirst_stranded.
- Docs.
docs/cultnet-transport-parity.md: the packet-type and flags rows of the header table (:486-487) gain "4with 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
Abandonpacket in the current generation (receiver). - Outputs: notices (sender); a remembered sequence, drained frames and one ack (receiver).
- Derived:
first_strandedis 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
Abandonbranch; no notice outlives its generation; no transport interprets bit 4. - Shared paths: data, fragments, duplicates, Accept and notices all advance
RthroughRememberReceivedand the one drain. - Deletion line: the plain drop in
purge_expired_reliablegoes 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
Abandonpackets) never acks, and the Rust sender strands the sequence after 16 sends; - the TS interop harness gains one lane: a Rust peer sends reliable
mediawith expiry whose first packet the bridge drops, then orderedschemaframes; the TypeScript receiver delivers theschemaframes (packages/cultnet-ts/test/interop/cultnet-interop.test.ts, beside the drop lanes); - negative grep:
rg -n "first_abandoned" packages srcreturns 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-cut4frommainafter Cuts 1, 1b and F merge. - First. Failing tests:
- P3: the receipt for
s2readsPendinguntils1arrives, thenAcknowledged; - P9 (
send_reliableon an expiring channel is refused) and P10 (a window-fullsend_reliablereturns a receipt covering its queued packets, and the frame is sent once); - a receipt
Acknowledgedbefore any ending staysAcknowledgedafter the ending and after a reconnect; aPendingone becomesInvalidated; - P2 with a new receiver: reliable
mediawith expiry is lost, then ordereds1,s2; their receipts readPendingwhile the notice is outstanding andAcknowledgedonce it is acked;flush_reliablesucceeds; - P2 with a deaf receiver (a stand-in session that drops
Abandonpackets): after 16 notice sends the receipts fors1ands2stayPending,flush_reliablefails at once naming the stranded sequence, and the ending makes themInvalidated.
- P3: the receipt for
- 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-838and:870-900.
- the session scope (
- 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, orfirst_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_reliablefails fast when an ordered send followsfirst_stranded, and otherwise waits while notices are outstanding (Cut F counts them inoutstanding_reliable_packet_count).
- the receipt's status cell and ordered flag; the session's unresolved-receipt list, resolved once
(Acknowledged through
- Unchanged:
CultNetRudpReliableSendStatus(:146-150),reliable_send_status's signature (:1931), and every caller ofAcknowledged:tests/rudp_stray_packets.rs:309,cultmesh-rs/tests/rudp_document_server.rs:408,415, CodexConnector, Ghostlight. - Changes:
cultmesh-rs/src/lib.rs:832and:892becomewait_acknowledged; the doc at:869says delivered to the document server's sink. - Verification:
- the new tests pass;
rg -n "session_scope|first_abandoned" packages/cultnet-rshas 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_acknowledgedis 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-215andsigned-daemon-health.ts:104-162(with its helpers:244-252,:273-389, except the exportedparseEndpoint:254-271) send throughCultNetRudpSocketTransportConnection:connectAndWait,send("schema"),flush,close. Their hand-rolled session loops are deleted. The presence publisher keeps rejecting acultnet.error.v0response, from the transport'sframeevents during the flush.connectAndWait(payload, timeoutMs)on the TS socket transport, matching C#ConnectAndWait(CultNetTransport.cs:1859-1880). It iscultmesh-ts/src/index.ts:6603-6650waitForRudpConnectedmoved 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-272gains: 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.tsandsigned-daemon-health.test.tsstay 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 frommainafter 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;
SendReliableon an unreliable channel is refused.
- Deletes first: nothing. These runtimes have no receipt code to replace.
- Adds, per runtime:
- C# (6a):
CultNetRudpSendReceipt,CultNetRudpSendStatus; inCultNetRudpSession, a send path that records every sequenceSendManyassigns (:1075-1140, beforeAdmitReliablePackets:1439-1465splits them), the unresolved list, resolution afterApplyAcknowledgements(:1503-1516) and in the Cut 1b primitive, andAover_pendingReliableand_queuedReliable; on the socket transport,SendReliable,WaitAcknowledged(theFlushReliableloop,:1914-1955), andSendSchema(:1960,:1968) andSendSchemaMessage<T>(:1976) returning the receipt; - TS (6b):
CultNetRudpSendReceipt, the status union; session changes atsendMany(rudp.ts:304-368),#admitReliablePackets(:586-592),#applyAcknowledgements(:610-617); transportsendReliableandwaitAcknowledgedbesidesend(:899-909) andflush(:911-935); exported from the package index; - Python (6c):
CultNetRudpSendReceipt,CultNetRudpSendStatus; session changes atsend_many(transport.py:538-588),_admit_reliable_packets(:776-786),_apply_acknowledgements(:803-807); transportsend_reliableandwait_acknowledgedbesidesend(:1000-1009) andflush_reliable(:1101-1131); exported fromcultnet_py; - README sections per §9, and the parity doc's flush paragraph.
- C# (6a):
- 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 (
rgfor 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 withygg-verify.sh):kotlin.Dockerfile:FROM eclipse-temurin:21-jdk;apt-get install unzip curl; fetchhttps://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 thenode:24image (COPY --from=node:24 /usr/local /usr/local) for the interop lane; PowerShell 7 (pwsh, Microsoft's Debian package) forbuild.ps1. Pass 3's probe proved the JDK-plus-compiler half: the self-tests exit 0 (K-img).ygg-verify.sh: akotlinimage alias tagged by that Dockerfile's hash, and the remote build step generalised fromeureka-verify-rust:*toeureka-verify-<name>:*built from<name>.Dockerfile(today it builds and copies onlyrust.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.ps1becomes portable PowerShell 7:kotlincorkotlinc.batby platform,[IO.Path]::PathSeparatorfor the classpath,javaorjava.exe. One build script stays the one owner of the Kotlin build; nobuild.shbeside it.- The interop harness calls
pwshoff Windows instead ofpowershell(packages/cultnet-ts/test/interop/cultnet-interop.test.ts:2799-2809); it already picksjavaoff Windows (:31-36). docs/cultnet-transport-parity.mdsays, beside the runtime table (:35) and the RUDP claims (:205-273), that Kotlin lacks the3bf1c0chardening 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 onwindows-lateststays 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 apacketsDroppedstat (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);
resetPeerStateandendRefusedSession: 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 beforereassemblecan 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);
connectclears a stale disconnect reason (K:3106); initialSequenceof0xFFFFFFFFis 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); seedRinacceptConnect(K:2667) and on Accept (K:2730); advance inrememberReceived(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-ConnectacceptConnect(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-kotlinreturns 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
3bf1c0chardening 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_sequencekeep 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
mediasender, 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:
Acknowledgedkeeps 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
- Done.
hands/rudp-stray-packetsmerged at3bf1c0c. Odinorigin/mainpins3bf1c0ce. - In flight (pass 2 text, unchanged by pass 3): Cuts 1 and 1b on
hands/ack-cut1, Cut 2 onhands/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.
- Ready now: Cut 3 (merge after Cut 1, as pass 2 said); Cut D (independent); K0 (tooling and
build.ps1; independent). - Cut 1c after Cut 1b merges.
- Cut F built on
hands/ack-cut2after Cuts 1 and 3 merge, once Q-A7 and Q-A9 are ruled. Cuts 2, 2c and F merge tomaintogether. - Cut 4 after Cuts 1, 1b and F.
- Cuts 6a, 6b, 6c after Cut 4, in parallel.
- Cut 5 after Cut 3 and Cut 1b in TS, as pass 2 said. It may use 6b's
waitAcknowledgedif 6b lands first; it does not need it (flush suffices). - Cut 7 after Cut F, once Q-A8 is ruled A.
- 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.
- 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
mediachannel) 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 ofinitial_sequence: 1should go at that bump (Cut 1c notes). - CodexConnector and Ghostlight need no code change; they may adopt
wait_acknowledgedafter Cut 4.
- Vendored TS bumps after Cut 5 (they pick up honest flushes, the TS window and the rewritten
publishers at once):
- StreamPixels vendors CultLib
542ddd3(StreamPixelsa49f7d8; pass 2's30ee8b9is stale). Its service runscreateIdunnRuntimePresencePublisher, which that revision carries under its older name (cultnet-ts/src/idunn-runtime-authority.ts, pin at:333). - Heimdall vendors
b6b1d9cand hand-rolls its publisher (src/idunn-rudp-health.ts:55-87,initialSequence: 1at:65). At its bump it calls cultnet-ts's publisher instead.
- StreamPixels vendors CultLib
- Parallel campaigns:
hands/media-fec-cut3touchesrudp.rsonly in the imports, theaudioprofile 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 onlymediain 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, Sleipnirmain.rs:880-904and 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'sCultNetPeer(peer.ts:98-110). - Kotlin self-tests live in the production source (
CultMesh.ktK:5236-5633, run frommain). 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
CultMeshMediahand-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. CultNetTransportStatsparity. Cut D addssend_failuresin Rust and C#; Kotlin has nopacketsDroppeduntil 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_sequencethat 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−1after ~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. WindowsConnectionResetErroron receive is idle, never fatal, in every server. Cut D's negative grepsend_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.lockis stale. - Also recorded:
packages/cultmesh-kotlin/build.ps1is Windows-only, so Kotlin interop runs off Windows only through an image with wrappers. - P2 (the expiry hole) stays with Cut F.
- Batch 2 fixed Rust
- 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 onetry, 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.
- cultnet-ts emits transient send failures as
- 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.