Identity: C2a's StoredSchemaId/StoredVersion owns; main's PersistedSchemaId, PersistedIdentity and both MarkWritten are deleted. Stamp rule: ReadStore judges the stamp before the body. Flush over damage refuses (merge-r1-precedence). Mesh single-file writes route through SingleFileMessagePackBackingStore. TS: store-file.ts commits, C2a's header handling kept. Rust: C2a's readable_store_format in front of the grown decoder. Build not yet verified at this commit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Deletes CultNetDocumentRecord, the DocumentPut and SnapshotResponse variants,
parse_cultnet_message, require_typed, round_trip_typed, the eight replication
functions and the two catalog schemas (parked at tag
parked/cultnet-rs-dynamic-document-lane). decode_cultnet_message_from_slice is
the one decode; raw v1 rows, identity credentials and the signed-record readers
decode through cultcache_rs::decode_payload. Adds tests/decode_door.rs. 0.2.0.
Not yet compiled at this commit (verification runs on Yggdrasil).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sixteen conflicted files resolved by the spec's rule: main owns payload
ownership, bounds and the Clock seam; C2a owns persistence semantics and exact
binding. MintStoredAt is internal static (previous, now); the cache and the
store pass their cache's Clock. Package versions are main's. The cultmesh-ts
payload-parse acceptance and its test are deleted under retire-schema-alias;
peer snapshot reads take the schema's own id, else an id the definition lists
as compatible. The held firstRecordAtKey fallback is removed: main's handles
take definitions only, so nothing could reach it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete the apply_raw_snapshot_documents extraction (no production change in cultmesh-rs remains):
the test serves a snapshot over loopback RUDP and pulls it through the node. Two at-limit documents
land; a document one level past the bound is refused with PayloadTooDeep and the store file is
byte-identical afterwards. Supersedes the seam test of 735654f0, which this commit removes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pull_rudp_catalog_snapshot hands its documents to apply_raw_snapshot_documents (the network-free
seam); the test feeds it a payload one level past the bound and checks nothing reaches the store.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
R1's rules win where both sides decided the same question: a zero-byte file is
not a store (refused as unreadable in every runtime), only nothing at the path is
empty, a dangling link is an I/O error, refusals never echo stored values and echo
a header only as cultcache.store.v<digits>, writes report Rejected, NotReplaced
and ReplacedNotDurable. C2a's rules win on what it added: the v3 element-id
header, variants, whole-file proof, typed unreadable errors in C#, TypeScript and
Python. Rust keeps R1's CultCacheStoreUnreadable{path, kind}; C2a's
StoreUnreadableError is deleted. The readability manifest's zero-byte row is
refuses x4.
Not yet built or tested: verification follows in the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
request_raw_snapshot_from_rudp_catalog put a catalog's error text into
its own error unfiltered. It now reports "refused the snapshot", naming
the refusal only when it is one of the fixed CultMesh refusal texts, as
the publisher does; both share recognised_refusal.
The publisher's doc claimed any refusing catalog is reported as a
refusal. That holds against a Rust catalog, which never acknowledges a
message it refuses; a catalog that acknowledges on receipt and refuses
afterwards (the TypeScript document server) is reported as having
admitted it. The doc now says so.
A fake catalog refusing with a canary is shared by the publisher test
and a new snapshot-client test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
end_unsendable_session put the io::Error's Display on the wire: the
platform's wording, which differs by OS and by which packet failed.
cultnet-rs gains send_error_code, the fixed name of a failed send's
cause (EMSGSIZE, EINVAL, EAFNOSUPPORT, or UNKNOWN), matching the codes
the TypeScript goodbye already carries; is_permanent_send_error is now
"has a known code". The goodbye reads "packet could not be sent: CODE".
ResponseSendFailed carried an ErrorKind, which lost the cause (EMSGSIZE
has no kind and read as Uncategorized). It now carries the same fixed
code, and the session ends where the send failed, over the real error,
so the goodbye and the caller's rejection name the same failure.
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>
A reply sent earlier in the same poll could fail permanently and then be
dropped when a rejection returned first; and a refusal fragment that can
never be sent must end the session naming its error. Both now reach
end_session as the unsendable end. The test makes the refusal itself
unsendable: the peer's goodbye names the error, nothing is counted as a
lost datagram, and the other peer is served.
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>
F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea,
I support retiring it." CultNetDocumentRegistry::register inserted into its
type and schema-id maps unconditionally, so the last binding for a schema id
won, as the TypeScript registry did before 9adea366.
- register returns Result and refuses a binding whose type already holds
another schema id, or whose schema id another type holds, with
cultcache_rs::SchemaConflictError: the new binding's schema id, both
document types (a binding carries no schema name), an empty record key.
Nothing is bound on refusal. Binding a type to the schema id it holds
again replaces the binding.
- Call sites propagate the refusal: the cultmesh-rs registry macro, the
interop peer example, and the tests.
- Tests renamed to what they test: raw snapshot replication hydrates the
receiver's own type for the schema; a reactive document coalesces direct
member writes.
- New test: both claimant shapes in both orders, the first binding intact
afterwards, and a same-type rebind replacing.
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>
- Document server: a reset reported by a receive is idle, not an error
(Windows only, where the system reports it; run on Starfire).
- Document server: no ack follows a reply that can never be sent. The
guard is not decoration: without it the ack's success overwrote the
failure and the session that owed the Pong was kept.
- Hub: a send refused at any fragment index delivers nothing of it; at
index 0 the next send arrives whole (Soul's probe, committed).
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>
The injected permanent failure now takes precedence over the injected transient
one, so a test can end a session on a peer that is also losing datagrams and
count exactly which datagrams were attempted afterwards. New tests: a hub
disconnect that can never be sent returns the error; a peer or session ended
in the resend loop is sent nothing more (Rust hub, document server, C#); the
goodbye reaches the peer; the Python server ends the session on a permanent
failure in a resend, an ack and an accept. The oversized-send test compares the
outstanding count with the one the hub's own unacknowledged Accept already
holds.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The hub and the document server swallowed every send failure as a counted
loss, so a 70,000-byte reliable frame returned Ok and was resent forever.
Failures now split in two. Permanent means the datagram can never be sent as
built (EMSGSIZE, EINVAL, EAFNOSUPPORT and their Winsock equivalents,
is_permanent_send_error in cultnet-rs); everything else stays a counted loss.
A caller-directed hub send returns the error and queues nothing: the packets
it created are withdrawn and their sequences reissued, so the peer never sees
a gap (a permanent failure after an earlier fragment left ends the session).
Inside a poll the failure ends that peer's session with a goodbye whose reason
names the error, reported to the hub caller as Disconnected, and never fails
the poll. The document server ends the session the same way and reports a
snapshot response it can never send as an application rejection. Tests pin
each path, the classification per OS, BytesSent after a healthy send and a zero
failure count for healthy sends.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ack Cut 1c made CultNetRudpSocketTransportOptions.initial_sequence optional; the
new client helper still passed a bare u32.
Co-Authored-By: Claude Sonnet 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 records the sequence of the Connect that started its generation. A
Connect repeats exactly when the session is connected and carries that
sequence; anything else ends the generation, forgets the peer and accepts a new
one. accept_connect answers both cases, connect_repeats is the predicate for
servers that keep one session per peer, and answer_repeated_connect is gone.
create_connect on a session that has had a peer starts a new generation.
An Accept is honoured only while this side's Connect is pending and only if it
names that Connect. The watermark is seeded by the Connect or Accept alone;
reliable data before that is neither remembered, acknowledged nor delivered.
create_ack_for_received acknowledges by name only what was received.
The default initial sequence is random in [1, 2^31). The hub, the cultmesh-rs
document server and the server-mode transport no longer compare payloads or
reset on every Connect. A receipt carries its session's random id. A timeout
names itself as the disconnect reason.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The cultmesh-rs document server and the cultnet-rs hub returned the first
failed send_to from maintain, poll_once, poll_resends and receive_event_once,
so one peer whose datagrams cannot be sent failed every poll for every peer.
send_packet now counts a send failure (send_failures() on the document server,
CultNetTransportStats.send_failures on the hub) and returns; only an encode
failure stays an error. The document server treats recv_from ConnectionReset
as Idle, as the socket transport does.
Tests inject the failure at the one send function through a cfg(test) set of
failing peers and prove the other peer is still served.
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>
CultNetMessage::Error gains typed code (CultNetErrorCode) and details
(CultNetErrorDetails) fields mirroring the landed C# reference
(CultNetErrorMessage/CultNetErrorDetails, CultNetSchemaMessages.cs). The
message now encodes/decodes through the raw cultnet.schema.v0 path
(encode_raw_cultnet_schema_message/parse_raw_cultnet_schema_message)
instead of the generic serde_json::Value round trip every other
CultNetMessage variant uses, because that path's BTreeMap-backed
serde_json::Value would sort keys alphabetically and break the C#
reference's fixed schemaVersion/error/routingHint/code/details map
order - every optional key is written nil, never omitted, matching
MessagePack C#'s always-five-key formatter.
Adds the SelectionRefusal -> CultNetMessage mapping the fix-batch-3
comment in selection.rs called out as pending on the C# shape landing,
covering all four codes (selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target).
cultmesh-rs's one CultNetMessage::Error match gets an added `..` so it
still compiles against the wider variant; it does not read code/details.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.
add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.
Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.
The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.
Version 0.1.0 -> 0.2.0.
Co-Authored-By: Claude Opus 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>