The RUDP option rules refused a TCP-only server (enable_rudp=False) for
RUDP limits it never uses. Rust checks those options only for the RUDP
server they configure, so Python now checks rudp_resend_delay_ms,
rudp_max_pending_reliable_packets, rudp_max_fragment_bytes and the empty
snapshot's fragment rule only when enable_rudp is true. The snapshot
limits still apply to every server.
The daemon turned --max-snapshot-documents 0 into a traceback. The server
still owns the rule; the daemon reports its refusal through
parser.error: exit status 2 and the rule named, no traceback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A well-formed put still killed the RUDP thread: _handle_raw_put called
apply_raw_put_message unguarded, and cache.put_envelope raises for a
blank recordKey, a payload that is not MessagePack or does not decode to
its schema, a global document under another key, or anything a
document's own decoder or validator raises. Over TCP it ended the
connection.
The apply site now refuses any put the store cannot take to the peer
that sent it, as malformed_document_put, over RUDP and TCP alike, and
logs the error's type. Neither the refusal nor the log quotes the error,
which can quote the put. No new shape checks: the store decides what it
can take.
The test sends each shape over RUDP and over TCP and shows the RUDP
thread alive, the TCP connection serving its next message, and other
peers served.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admission has sized only the served reply since a1065cab; three comments
and one fixture assertion still described the receipt bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cargo-mutants found no test of what CultCacheStoreWriteFailed says. Both
outcomes now assert the path and the outcome in the error text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
with_shared_lock, with_exclusive_lock, try_with_exclusive_lock and the redb
store's with_database returned the unlock's error in place of the action's
result, so a write that had replaced the store could be reported as failed
and read by Odin as not replaced. Closing the lock file releases the lock
whether or not the explicit unlock succeeds, so all four now release through
one helper that returns the action's result. The unlock failure is not
surfaced anywhere: there is no diagnostic channel on these paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stryker on 43ed9396..3763a53c left 20 survivors, in two places:
- cultMeshCatalogWriteArguments. No test wrote through a catalog's
replace or submitPrediction, so which argument was the context went
unobserved. A test now writes through both, in every calling form,
and checks the context and value that reached the document.
- The catalog refusal's schema names and message. The refusal test now
checks both names and that the message names both types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every single-file write now carries CultCacheStoreWriteFailed in its error
chain, above the cause. NotReplaced: creating, writing, syncing or renaming
the staging file failed, and the store file holds what it held before.
ReplacedNotDurable: the rename landed and the directory sync failed. Odin
could not tell these apart, so it kept every failed snapshot as a candidate.
The text names the path and outcome only.
fsync does not fail on a healthy filesystem, so the staging and directory
sync steps take a fault armed per thread by tests; builds outside tests never
fail there. The staging write (RLIMIT_FSIZE child) and the rename (a
directory at the store path) fail for real.
README: the write outcome, the dangling-link rule and the no-echo rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The registry method existed on this branch only to size a receipt at put
admission in cultmesh-ts, which no longer does. It never reached main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admission sized the larger of the snapshot reply and a receipt echoing
the put, so a put servable at the bound was refused, within 89-125 bytes
of it, for a receipt an application that sends none never sends. Whether
a receipt is sent is onDocumentPutRaw's to decide after it stores the
put, so admission cannot know it (ruling: Self, fork A option a).
Admission now sizes only the snapshot reply serving the document alone.
A receipt that cannot be sent takes the existing path: onError hears why
and the peer is told with a cultnet.error.v0 that it is not coming. This
removes the receipt bound of 9757ed54 and its test.
The new test pins the band at its boundary with puts whose echoed receipt
is over the limit: exactly at the limit is admitted and served, with no
receipt and with an echoed one, and one byte over is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RequireSingleFileFormat echoed any header string. It now names one only in
the shape cultcache.store.v<digits> and otherwise gives its byte length.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unsupported header is echoed only as cultcache.store.v<digits>; any other
is described by its byte length (a 2,914-character header was echoed whole).
pullAll's ENOENT catch wrapped the whole decode, including the legacy-repair
write, so a failed repair read as an empty store. It now covers only the read,
and only nothing at the path is absent: a dangling symbolic link is refused
instead of being replaced by the writer. A soft pushAll uses the same read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unsupported header is echoed only as cultcache.store.v<digits>; any other
is described by its byte length, since the bytes are the store's.
A read that found nothing through a dangling symbolic link returned an empty
store, and the writer then replaced the link. Both stores now share one read
rule: only nothing at the path is absent. JsonLinesBackingStore also drops
`Path.exists()`, which reported a symlink loop or a file-as-parent as absent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"cultcache.store.v" is a prefix of the "cultcache.store.v1" the refusal names,
so the unquoted check failed on a correct refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4002a996's refusal caught the self-test itself: the reconciliation block
registered kotlin.reactive_note beside kotlin.reactive_note.ui under one
schema id in one cache. The block tests reconciliation, not two types for one
schema, so its note is now kotlin.reconcile_note under its own schema id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A decode refusal kept serde's text, which quotes values read from the file
(`invalid type: string "..."`). The detail is now CultCache's own context and
record refusals, which name only a record's key and schema id, plus a fixed
description of the decoder failure's kind. rmp-serde reports no position, so
none is given. An unsupported header is echoed only in the shape
`cultcache.store.v<digits>`; any other is described by its length.
`is_absent` used `fs::metadata`, which follows links, and a read that found
nothing through a dangling link returned an empty store, so a writer then
renamed a regular file over the link and the store left its volume. Only
nothing at the path is absent now (`symlink_metadata`), and a read through a
dangling link is an io::Error.
Tests: canaries for a catalog and a legacy value and for unshaped headers,
the dangling link on every read with nothing written, and a
`cultcache.storeX` header that claims no format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
43ed9396's type test caught it: TypeScript inferred the catalog value type
from a written value or an expected result through the conditional's false
branch, so replace and submitPrediction accepted any value and an annotated
collection read took the annotation as its type. The false branch is now
NoInfer, so only a definition or an explicit type argument decides it.
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>
The daemon test ran with --max-snapshot-documents 0 to make every
snapshot a peer error. A limit of zero is now refused at startup, as the
Rust document server refuses it, so the test pins what the daemon does
with it: it exits non-zero naming the rule and never writes its ready
file. The limit's enforcement as a peer error stays pinned in process by
test_cultmesh_local_server_rejects_oversized_snapshot_responses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stryker left one survivor on 9adea366: emptying the refusal message. For a
same-type claimant the message did not name the schema id at all. It now
names the claimant's schema id in every case, and the refusal test checks
that it does. The message is built from document types and the schema id
only; registration carries no record, so no record value can reach it.
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." CultCache.register wrote its type and schema maps
unconditionally, and put, get, getAll and delete register implicitly, so one
Kotlin cache could hold two types for one schema and codecForSchema answered
with the last.
- register refuses a codec whose type already holds another schema id, or
whose schema id another type holds, with the new
CultSchemaConflictException (schema id, both document types, empty record
key), the Kotlin counterpart of the other runtimes' typed conflict. The
implicit registration in put, get, getAll and delete goes through the same
check. Registering the same type for the same schema again stays allowed.
- Self-test: other-type same-schema and same-type other-schema claimants,
through register, put, get, getAll and delete, each in a fresh cache; the
first codec still answers for its schema afterwards.
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>
_unservable_put sized a put by building the envelope it would store, and
that read document["recordKey"] and document["payload"] directly: a put
over RUDP with no recordKey raised KeyError out of the RUDP loop and
ended the thread for every peer. A string payload raised TypeError the
same way.
_unservable_put now refuses a put whose document lacks a string recordKey
or a binary payload as malformed_document_put, the code the server
already uses for a put with no document map, before any sizing reads it
and before its early return, so the TCP path refuses it too instead of
raising in apply_raw_put_message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With one-byte fragments and a reliable queue of 16, even an empty snapshot
can never be queued: every reply vanished while rudp_send_failures
counted, and the peer timed out. Rust refuses such a configuration at
startup in validate_options; Python's __post_init__ accepted it.
__post_init__ now applies Rust's rules to every option the two servers
share, in Rust's order: max_snapshot_bytes, max_snapshot_documents,
rudp_resend_delay_ms, rudp_max_pending_reliable_packets and
rudp_max_fragment_bytes must be greater than zero (the first two when
set; zero was accepted before), max_snapshot_bytes must hold an empty
snapshot response, and an empty snapshot response in fragments of
rudp_max_fragment_bytes, counted rounding up, must fit the lesser of
65535 and the reliable queue. Rust's session, lifetime and payload-budget
rules have no Python option to apply to.
The test measures the empty snapshot the server actually sends, pins each
rule at its boundary, and shows the configuration at the bound does
answer.
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." A catalog indexed handles by schema id and by schema
name and version with plain map writes, so a handle of another type for a
held schema silently replaced it for every lookup by schema alone.
- Document and collection catalogs refuse, with the SchemaConflictError the
registries use, a typed handle whose type differs from the one holding its
schema id or schema name and version, or whose type already holds another
schema id. Nothing is indexed when a handle is refused.
- Allowed: handles of one type for one schema (one per record), the same
handle again, and a handle without a type, which claims none.
- Test: other-type same-id, other-type same-name-and-version and same-type
other-id claimants, both orders, via add and via construction, for both
catalogs; the first handle still answers afterwards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deleted parse option was the only thing that typed a catalog lookup, so
every caller had to name the value type. Catalog methods now take the schema
argument as a type parameter: a CultCache document definition yields its own
value type, an explicit type argument still wins, and a bare schema
descriptor yields unknown. CultMeshCatalogValue names that rule.
- Document catalog: tryDocument, document, latest, replace,
submitPrediction, authoritativeWriter, predictionWriter, watch.
Collection catalog: tryCollection, collection, latest, watchChanges.
- The two copies of the (value, options) / (context, value) argument split
collapse into cultMeshCatalogWriteArguments, and the typed lookups share one
private #require per catalog.
- Tests: the three reads that named their type infer it again. A type-level
test pins inference for reads, rows, watches, writes and writers with
@ts-expect-error on wrong assignments.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The EMSGSIZE pre-check measures a send's largest datagram, which is one
fragment when maxFragmentBytes is set. Nothing pinned that: a check that
compared the whole payload (Soul's S-TM2) would refuse every large
fragmented send, and large sends are live. A 70,000-byte send with
1,024-byte fragments must not throw and must arrive whole. The test
kills the mutant whose size check ignores maxFragmentBytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RudpCultNetSchemaServer decoded each schema frame outside its per-message
backstop, so a malformed payload (0xc1) or a well-formed message with an
unknown schemaVersion threw MessagePackSerializationException out of
PollAvailableAsync and PollOnceAsync. The drain aborted: a good peer's
request in the same drain went unserved, a consumer loop that did not
catch died, and the bad frame was already acknowledged while its peer
stayed connected.
Decoding now runs inside the backstop. A peer whose message cannot be
decoded or handled has its session ended: the transport's new
EndRefusedPeer sends the refusal goodbye under the gate, counts the drop
in PacketsDropped, drops that peer's frames still queued, forgets the
peer and raises PeerDisconnected. A session that already ended (replaced
by a new Connect, or ended by a send failure) gets no goodbye, since the
endpoint may belong to a new session. Other peers in the drain are still
served. Ending the session matches cultmesh-py's server, where one
peer's bad message ends that peer; runtime parity is the reason.
The log names the fault's type only and never its message, because the
decoder's message quotes the peer's input (the unknown schemaVersion).
The dispatch-backstop test now asserts that rule instead of the handler
message it used to require.
Tests, each failing on the pre-fix code: a malformed payload and an
unknown schemaVersion each leave the good peer served without Poll
throwing (also through PollOnceAsync); the bad peer's session ends,
counted and told; the log carries the type and not the canary value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Send ended an unsendable session after releasing SessionGate, and the
goodbye was built under the gate but sent outside it. If the polling
thread admitted a new Connect from the same endpoint in that window, the
ended generation's goodbye reached the new client: it saw Data, Accept,
Disconnect and believed itself disconnected while the listener held it
connected.
Every path that finds a permanent send failure now reads the session's
generation under the gate at the send, and EndUnsendableSession builds
and sends the goodbye under the gate only while that generation is
unchanged. The generation check replaces the UnsendableReason once-guard:
a second failure in the same generation finds it already ended.
The test orders the new Connect inside the send, before the fragment
that can never be sent, and asserts the new client hears no Disconnect
and stays connected on both sides. It fails on the pre-fix code
(Data, Accept, Disconnect) and kills the mutant that drops the check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the C# side of the cross-runtime rule the Rust, Python and TS runtimes
now share. C# already refused a zero-byte file (the snapshot reader runs out
of bytes at the array header); this test keeps it that way. Its exception is
still EndOfStreamException, an IOException: that overlap belongs to R3's C#
work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalogs' parse option went with asSchemaAlias, and with it the only
thing that inferred a catalog lookup's value type. Three test reads now pass
the note type explicitly, as a caller does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An existing zero-byte file is decoded and refused, like any other
undecodable store, matching the Rust and C# runtimes. A missing file stays
empty (ENOENT only), and tests pin that a symlink loop and a file-as-parent
surface as ELOOP and ENOTDIR. `[0x90]` stays an empty legacy store.
Refusal text no longer points at a variant-aware runtime: an unsupported
header names the format found, and a v1 record with a fifth slot says the
file is not a valid store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Path.exists()` returns False for ELOOP and ENOTDIR, so a symlink loop or a
file where the parent directory should be read as an empty store. The read
now catches FileNotFoundError only. An existing zero-byte file is refused
with ValueError, like any other undecodable store, matching the Rust and C#
runtimes. `[0x90]` stays an empty legacy store.
Refusal text no longer points at a variant-aware runtime: an unsupported
header names the format found, and a v1 record with a fifth slot says the
file is not a valid store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The single-file reads tested `path.exists()` and treated false as an empty
store. `exists()` is also false when the path cannot be inspected, so a
symlink loop, a file where the parent directory should be, or a parent
without permission read as an empty store, and a writer would then replace a
store it never saw. The read now maps only ErrorKind::NotFound to empty;
every other failure stays an io::Error. `pull_all`'s lock-free absence check
gets the same rule.
A store file that exists with zero bytes is Undecodable: no CultCache writer
produces one. `[0x90]` stays an empty store: it is the legacy envelope array
with no envelopes and carries no header, so it claims no format.
Refusal text: an unsupported header names the format found and the one this
runtime reads, and a v1 record with a fifth slot says the file is not a valid
store. Neither suggests a variant-aware runtime; the C# reference refuses the
same v1 bytes.
Tests pin a symlink loop and a file-as-parent on every read, the empty file,
the newer-header truncation boundary (1-19 bytes Undecodable, 20 and up
UnsupportedFormat), the refusal text, and staging cleanup when the rename
fails rather than the write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F7 retires schema aliasing (one type presented as another for the same
schema). The Rust and Kotlin rows called their snapshot hydration "same-schema
alias" hydration. Both decode a raw record by its schema id into the one type
the receiving runtime registered for that schema; neither has a handle
aliasing API. The rows now say that.
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." One type owns a schema, so a handle never needs
presenting as another type for the same schema. Parity with C# 1e045502.
- Delete asSchemaAlias from the document, bound document, collection and
bound collection handles, and cultMeshSchemasAreCompatible, which only it
and the catalogs' alias step used.
- Document and collection catalogs answer a typed lookup from that type's
handle or not at all. A descriptor without a type is still looked up by
schema id, then schema name and version. The parse option on every catalog
method existed only to convert an aliased value, and is deleted.
- CultMeshNode passes the caller's definition to its cache. The node no
longer resolves another type's definition by schema id, and no longer
re-parses values into the requested type, so a type the node did not
register is refused by the cache. node.get and node.put now return the
cache's own value, as CultCache.get and put do.
- Tests: the noteAliasDocument fixture is deleted. Tests that used it for
publication, sync, snapshot, store and prediction paths now use
noteDocument. otherTypeForNoteSchema stands for another type claiming the
note schema: catalogs do not find it, and a node refuses it on every path
and refuses to register it beside noteDocument.
- README stops documenting node aliasing.
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 silently replaced
the binding for a schema id, so two types could claim one schema and the
last one registered won.
- register refuses a binding whose definition claims a type, schema id or
schema name another bound definition already holds, with the
SchemaConflictError CultCache uses for the same refusal: the new
definition's schema id, both schema names, an empty record key.
- Binding the same definition again still replaces its binding, and a
definition listing another's id as compatible is not a second owner.
- Test: identical-id, same-name and same-type claimants refused in both
registration orders, leaving the first binding in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator: "FEC: Please own the math." rs-gf256-v1 implemented in
media_fec.rs, wire-identical to the crate (1,795 geometries, independent
derivation, 6,520 cross-version recoveries, 60k hostile blocks identical).
lru and instant advisories gone; 13 fewer dependencies. combine uses one
product row per coefficient: encode ~1.0-1.4x, decode ~1.2-1.4x the crate.
Follow-up: k^3 inversion makes 255+1 recovery ~124 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NU1101 since 69bd45ff: pack-nuget.ps1 packed a hand list that missed
GameCult.Math; it now follows ProjectReferences from the public roots.
ubuntu timeout since 2dc7c122: the headless deadline started at connect.
Green on both OSes: run 36727267703 (ubuntu passed twice).
Follow-up (Eve): pack-dotnet-surface.ps1 ignores the version parameters
the verify script passes, so the local feed reuses published versions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file that opens like a store but carries some other string where the
header goes is undecodable, not a store in a format this runtime does not
read. cargo-mutants left the guard's `true` mutant alive without this case.
The size-limited child's stdout and stderr now travel with the parent's
failure message, so a child that fails for another reason says which.
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." C# now holds one type per schema, as TypeScript,
Python and Rust already do.
- Delete IsExactWireAlias and the branch that registered an identical second
type by CLR type only. A second type with the same schema name and version
is refused with CultSchemaConflictException naming both types and the
claimed id, whether it is identical, declares other compatible ids, or has
other members. The untyped DuplicateSchemaRegistration refusal and the
separate SchemaIdClaimedTwice path collapse into that one refusal.
- Owner-beats-lister (batch 10) is unchanged: one type owns an id, others may
list it as compatible.
- The per-name version index stays; it serves v1/v2 of one schema name, not
aliasing.
- Tests: the alias-registration tests are replaced by one refusal test over
identical, re-declared and re-shaped second types, in both registration
orders.
- cultcache-store-composition.md drops the AsSchemaAlias stale-protection note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>