The queue-full test used an empty queue, so a reliable refusal fitted and
the reliable-refusal mutant lived. The new test fills a queue of 8
exactly with a reply the peer never acknowledges; the next reply cannot
be queued, and its refusal must still arrive, acknowledging nothing the
peer sent. A RawPeer helper drives the peer packet by packet; the gap
test uses it too.
A response too large for any datagram fails with the kernel's EMSGSIZE:
the rejection is ResponseSendFailed("EMSGSIZE"), the goodbye is exactly
"packet could not be sent: EMSGSIZE", and no refusal is sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
receive() returns a reply only for a Ping, answered with a Pong, and a
Ping delivers no frame, so no rejection can follow an earlier send in the
same poll. The arm that carried such a failure into the rejection's end
was dead code; its mutant survived for that reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Send-failure semantics are Cut D's: a transient failure is counted and
never reported; a permanent one ends that peer's session.
Conflicts:
- cultmesh-ts index.ts: the two sendPackets. Cut D's
sendPacket(record, packet) is the one; the branch's reporting
sendPacket is deleted, and so is the options doc's "Every failed
datagram send reaches onError".
- cultmesh-py server.py: the rudp_send_failures doc is Cut D's. The
receive loop is Cut D's (handling guarded, permanent send errors
carried) with the branch's over_rudp=True. _send_rudp_schema_frame
sends in rudp_max_fragment_bytes fragments and returns Cut D's
permanent error from any fragment; a reply the queue cannot take no
longer vanishes into a count (below). _send_rudp_packet and the two
peer-ending helpers are Cut D's.
- cultmesh-py test imports: both create_rudp_schema_transport and
decode_rudp_packet.
Semantic resolution:
- cultmesh-rs: end_rejected_session and Cut D's end_refused_session and
end_unsendable_session collapse into one end_session(key, SessionEnd).
A refusal fragment that can never be sent is not dropped: the goodbye
names its error. The refusal stays unreliable, unordered, and
acknowledges nothing (its ack fields are built after the reset).
- cultmesh-rs: Cut D's "snapshot response could not be sent: {error}"
becomes CultMeshRudpRejectionReason::ResponseSendFailed(ErrorKind),
with a fixed REFUSAL_TEXTS entry. Its session ends as unsendable: the
peer gets the goodbye naming the error and no refusal.
- P3: a snapshot the reliable queue cannot take is answered.
cultmesh-py sends a small cultnet.error.v0 (response_unqueueable,
fixed text) unreliable and unordered, then the session ends as Cut D
ends any message the handling cannot serve. cultmesh-ts sends its
snapshot fallback error unreliable and unordered. cultmesh-rs already
answers through the refusal (ResponseQueueFailed); a test pins it.
- Tests follow: the branch's EMSGSIZE tests now expect Cut D's permanent
handling (the TS one is deleted, Cut D's own tests cover it), and the
Python queue test expects the answer instead of a count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal went reliable and ordered, but the session ends with it, so
it is never resent: a peer missing any earlier reliable packet from the
server held it behind that gap forever. It now goes unreliable and
unordered, so it is delivered on arrival whatever the peer is missing.
The test drops the server's snapshot reply before the refused put: the
refusal still arrives, followed by the goodbye.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal sent to a rejected peer carried the rejection's Display
text, which for SinkRefused and SnapshotSourceFailed is the sink's or
source's own error. Odin's sink formats the peer-supplied document type
into its error, so a peer's input came back on the wire.
The refusal now carries fixed text per rejection variant
(REFUSAL_TEXTS). The rejection returned from poll_once still carries the
detail for the caller; the server itself logs nothing.
publish_cultnet_message_to_rudp_catalog no longer quotes a catalog's text
unfiltered: it reports "refused the put", quoting the text only when it
is one of the fixed refusals.
The test fixtures' sink and source errors carry a canary; the refused
peer's text is the fixed one and never contains it. A fake catalog that
refuses with a canary of its own shows the publisher's error stays
fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refused peer now acknowledges its refusal, and that datagram reaches
the server after the session has ended. The test took the next poll to
be the second client's request and failed on the dropped acknowledgement
instead. It drains the server until idle after each refusal is read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every application rejection, DocumentUnservable included, ended with
self.sessions.remove(&key): no refusal and no goodbye. The peer could not
tell a refusal from packet loss, and a publisher timed out and retried
the same put.
The session still ends, but the peer is now sent the refusal as a
cultnet.error.v0 carrying the reason's text (no code or details, as the
TypeScript server's refusals carry none), then the session's goodbye.
The refusal is built before the reset and carries the goodbye's emptied
acknowledgement fields, so it never acknowledges the refused message:
publish_cultnet_message_to_rudp_catalog reads that acknowledgement as
admission. A refusal that cannot be encoded or queued is not sent; the
goodbye still is.
publish_cultnet_message_to_rudp_catalog now returns a catalog's refusal
as an error naming it, instead of waiting out its flush deadline.
Tests read the refusal from the client side: the refusal text, the put
still pending after it, and the goodbye invalidating it; and a publisher
refused by a sink hears the sink's reason well inside its deadline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test Source overrode served_record in both modes, so no test ran the
trait's default and a default that stripped provenance survived. Source
now keeps the default, which the at-bound fixture pins (the record as
received); a separate StrippingSource overrides it for the stripping and
failing-source tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMeshRudpSnapshotSource gains served_record: the record the source
would serve for a received put's document. Admission sizes that record,
so a source that serves another shape than it receives (dropping
provenance, or adding its own, as Odin's presence records do) keeps
admission exact by overriding it. The default serves the record as
received, so existing sources, closures included, are unchanged. A
source that fails to answer refuses the put as SnapshotSourceFailed.
Tests: a stripping source admits a put that is over the bound only as
received and serves it at exactly the bound; the existing at-bound
fixture stays as the default-method test; a failing served_record
refuses the put.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A put of 55 fragments leaves the client in flow-window steps, so the
server hears it only while the client reads acknowledgements and resends.
The helper that waits for a refusal or a stored put now pumps the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit cleared source provenance from every served record.
Odin's snapshot source serves runtime-presence records with provenance it
authors itself (source_runtime_id, source_agent_id, source_role and an
odin-observed tag), and a crate test pins that a raw response is served as
the source returned it. The server does not own that shape, so it no
longer rewrites it, and admission again sizes the record as received.
Which rule a Rust server should size by is reported as a fork.
The fragment-count admission, the construction cross-check and the
exact payload-budget fixture stand. The budget fixture's put carries a
longer message id so its frame, the exact budget, is at least its
served-alone response. Polling for a refusal sleeps when the server is
idle, so a put of many fragments has time to arrive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A served record carries no source provenance: the server now clears
source_runtime_id, source_agent_id, source_role and tags from every record
it sends in a snapshot response, as the TypeScript and Python servers
already serve only what their stores keep. Put admission sizes that served
record, so provenance that makes a put larger only in its received form no
longer refuses it; the sink still receives the record as it was sent.
Admission also counts the response's fragments: a put whose served-alone
response would take more fragments of max_fragment_bytes than
max_pending_reliable_packets_per_session or 65535 is refused with
DocumentUnservable, which now names the fragment count and its limit.
Construction refuses options that could not carry an empty snapshot
response, in bytes or in fragments.
The payload-budget fixture sets both budgets to exactly the put's frame, so
a charge equal to a limit must fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The served-size check built its response under an empty message id, which
the CultNet encoder refuses, so every put was rejected as
ResponseEncodingFailed. No snapshot request can carry an id shorter than
one character, so a one-character id gives the smallest response that could
ever serve the document; that is the size a put is now judged by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server admitted a put up to the 4 MiB per-session budget but serves at
most max_snapshot_response_bytes (1 MiB by default), so it could accept a
document no snapshot request would ever return.
DocumentPutRaw now sizes the snapshot response that carries the document
alone under an empty message id, the smallest response that could serve
it, with the snapshot path's own encoder. Over max_snapshot_response_bytes
it is refused as DocumentUnservable, naming both sizes, before the sink
sees it. At the bound it is admitted and served at exactly that size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMeshRudpApplicationRejection.reason was a String, so a consumer could
only log a refusal, never act on its cause. It is now
CultMeshRudpRejectionReason, one variant per refusal the document server
makes, with sizes and limits as fields. Display keeps the existing
sentences, so `{}` logging reads the same.
The snapshot path encodes its response through one helper, which put
admission will share.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A frame at or before the seeded watermark is a duplicate of an earlier generation and is acknowledged, never delivered again (serial compare within the receive window). A Connect that precedes the current generation's within the window is a stale copy of an earlier attempt: it is answered like a repeat (Accept owed or Ack), changes no state and does not refresh liveness. A Connect unanswered for 3 s is replaced by a fresh Connect with a newly drawn sequence and the same payload. In server mode a Connect from another endpoint is always a new generation. The initial sequence is unset by default and each session draws its own; the public random helper is removed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Session and transport tests for the rules the change makes: a reconnect on the
same session and a server that accepts a new client are not stranded by what an
ended session owed (Soul F1); a retransmitted Connect after data has flowed does
not reset; a Connect with another sequence is not a repeat whatever it carries;
an Accept counts only when it names the pending Connect, and not after a local
disconnect (F6); reliable data before the Accept is neither delivered nor
acknowledged (F2); the default initial sequences are random; the hub, server
mode and the cultmesh-rs document server admit a restarted client; a receipt
asked of another transport reads Invalidated (F4); a timed-out flush names the
timeout; the hold buffer takes exactly 1024 frames and 64 channels (F5).
Existing tests that handshook with a hand-built Accept now use assume_connected
or a real handshake, and pin distinct sequences for peers that must differ.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A session that refused a packet, or was disconnected, forgot its
unacknowledged writes, so flush_reliable returned success for writes the peer
never acknowledged. The transport now fails the flush with the reason and
reads such writes as Invalidated. Reset no longer reissues sequence numbers or
fragment ids: a late duplicate Connect in server mode reused sequences the
peer had seen. end_refused_session owns the reset-then-goodbye ordering for
the hub, the transport and the document server, which used to end a refusing
session silently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Disconnect a refusing session sent carried the ack of the very frame it
refused, so the sender saw the frame acknowledged. The session forgets its
peer's sequences before building the goodbye, and a server-mode transport
forgets its endpoint so only a Connect can claim it again.
CultNetRudpSession::answer_repeated_connect is the one answer to a repeated
Connect for the hub and the document server: it records the sequence (the
document server's old accept_connect did, and a client that reconnects in one
session depends on it), applies acks, queues nothing, and returns the pending
Accept or an Ack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The document server called accept_connect again for a Connect on an existing
key, queuing another reliable Accept each time: a resend storm, and the full
queue Err that the previous commit turned into a drop. The repeat now re-sends
the Accept still pending, or an Ack once acknowledged, as the hub does. A fresh
session's accept_connect cannot fail under validated options, so its error is
propagated again rather than counted as a drop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Connect storm from a flow that never hears its Accept filled the session
reliable queue and accept_connect returned Err out of poll_once. A refused
Connect, a malformed frame and a packet for an unknown session are now
counted in packets_dropped() and poll_once keeps serving.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`media` was hardcoded Reliable in two places — the profile builder and the
per-channel send options — so no caller could ask for anything else.
Measured on Raven through `cultnet-impair`, 848-byte packets at 12 Mbps, which
is Muninn's real media shape:
profile mode delivered ack bytes
clean unreliable 100.00% 43
clean reliable 69.10% 131,322
loss 1% unreliable 98.95% 43
loss 1% reliable 72.00% 135,837
loss 3% unreliable 96.73% 43
loss 3% reliable 73.30% 138,030
A reliable media channel loses 31% of a clean 12 Mbps offer, and its delivery
barely moves as loss rises, so loss is not the binding constraint — it cannot
carry the rate. An unreliable one holds everything on a clean link and then
degrades by about the loss injected. The reverse channel differs by three orders
of magnitude.
Muninn wanted the unreliable lane and had no way to say so. It signalled the
intent the only way the API allowed, by passing no reliable expiry, and its own
test has asserted Unreliable and failed since it was written. With this change
it passes.
Delivery now comes from the advertised profile, resolved by `channel_delivery`
alongside the `channel_reliable_expire_after_ms` that already worked this way.
That removes the send path's private opinion about reliability, so the profile a
peer was shown and the behaviour it gets cannot disagree. The channels whose
semantics are fixed by design — schema reliable and ordered, realtime unreliable
— are untouched and pinned by a test.
`media_delivery: None` means Reliable, so no existing consumer changes
behaviour. It is still source-breaking for anyone constructing the three options
types with a struct literal rather than `::client(...)`: two sites in
cultmesh-rs, four in Muninn, seven across the test suites here, all mechanical.
An earlier draft claimed Muninn was unaffected; the compiler disagreed.
The `examples/media_delivery_probe.rs` added here is what produced the table, so
the claim can be re-run rather than believed. Note its sender-side wire-cost
figure is unsound for the reliable case: the sender stops draining after five
seconds while retransmission is still in flight, so those byte counts
under-report. The delivery percentages are receiver-measured and stand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CultLib's packages/cultmesh-rs and Odin's vendor/cultmesh-rs had diverged
independently -- 87 shared symbols, 6 only here, 9 only there. Neither was
simply ahead of the other, so this is a merge rather than a fast-forward.
Odin's copy is the base because it is the deployed code, running in production
via Idunn, and because it carries a deliberate semantic upgrade: connection_id
was replaced by a per-request message_id correlation with validation requiring
raw callers to supply one. That brings in the RUDP document server (647 lines)
and its tests (553), which the original migration into CultLib left behind.
Layered back in from CultLib's lineage: soa(), get_all_with_keys(),
create_rudp_document_message(), and the plural batch publisher. The batch
function was rewritten off the removed connection_id onto the connect helper.
Both publish paths survive deliberately. They are different jobs, not rival
implementations: the singular path polls one receipt for application-level
acknowledgement and can report Invalidated, while the batch path sends and
then flushes at the transport level. That is also why the census framed the
30s-versus-300ms flush_timeout as a value conflict and was wrong -- one field
name, two mechanisms. The distinction is now documented at the call site.
Surviving connection_id references are the RUDP protocol's own connection
epoch and are unrelated to the removed options field.
cargo test: 16 passed, 0 failed -- 10 lib, 6 document-server integration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>