CultMeshRudpSocketOptions.BindHost defaulted to 0.0.0.0 for servers and
clients alike (a58daa54, so clients could reach remote routes). That bound an
IPv4 socket for every client, so a client for an IPv6 endpoint could not send,
and a client for a loopback endpoint listened on every interface.
BindHost is now nullable and unset by default. Unset, a client binds loopback
for a loopback endpoint and the unspecified address of the endpoint's family
(0.0.0.0 or ::) otherwise, matching cultnet-rs, cultnet-ts, cultnet-py and
cultmesh-kotlin. A server keeps binding 0.0.0.0. An explicit BindHost wins for
both. The test that pinned the old default's value is replaced by tests of
what each endpoint binds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut 1: one watermark owns ordered delivery, seeded only by the handshake.
Cut 1b: every ending drops what the session still owed; flush is bound
to its generation. Cut 1c: a Connect repeats only by its sequence;
stale data and stale Connects are handled as TCP does (challenge Ack);
an unanswered attempt retries at +window-1; the session draws its
initial sequence. Wire bytes unchanged in all four runtimes. Five Soul
passes. Public option types changed: initial_sequence is optional.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a Connect awaits its Accept, only an Accept the client honours retires it. An Ack that names the Connect (a server's reply to a repeat or a stale copy) no longer wedges the client with nothing to resend. The Connect payload lives on the session, so a fresh attempt can always be built. The fresh attempt uses the abandoned sequence plus the receive window less one (serial), so a late copy of the abandoned Connect stays inside the new one's stale window; only a session's first Connect draws at random.
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.
An accepted peer now stamps its liveness on accept, so one that goes silent times out.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
F1: RecordUnappliedEntry keeps the primary's wire entry on the in-memory log
entry (internal Relayed) and ToLogEntryMessage returns it through the same
normalisation the store uses, so a replica with no store no longer relays a
skipped put as a delete; memory and disk relay identically.
Soul's 4d probes are committed (chained replica keeps the row, key-prefixed
reads serve only owned keys, primary-all serves everything; the two
measurements are explicit) with a memory/disk relay-parity test.
Rust: captured-bytes cases for unowned_schema and variant_unsupported (with and
without a subscription).
The error-code set is stated as a closed enum in the schema description (both
copies) and in cultnet-distributed-database.md: adding a code is one
coordinated change across runtimes, since Rust and TypeScript refuse an unknown
code. The Soul 4c probe file's header says what it guards.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A bounded session whose queue still held an unacknowledged Accept for a peer
that vanished refused the next Connect; the old server-mode path reset before
accepting, so this would have been a regression. accept_connect and
create_connect now end the generation first in every runtime, and a test per
runtime holds a one-slot queue to it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two rules the first mutation pass found undefended in every runtime: a new
Connect on a session that had a peer clears the old peer's received window (a
restarted server's sequences overlap the old ones), and ending a session
abandons its pending Connect. The cultmesh-py server test now counts the
server's own data packets by sequence, so a peer replaced on a retransmitted
Connect is seen, and asserts a restarted client inherits no subscription.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The dispose test showed journal-first alone still lost the change: Dispose won the race against the writer's publication and cleared the stash. Dispose now drains the stash through the publisher after the journal is disposed (so nothing is stashed afterwards) and before the subject completes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Q1: HandlePutAsync/HandleDeleteAsync catch a CultNetShardLogException (or an
AggregateException holding one) before the generic catch and answer nothing: the
write committed and was published, so the peer is not told it was refused. It is
logged at warning level. The wire has no committed-with-degraded-log message.
Q2: GetAsync/GetAll/GetByName/GetByIndex, the shard-less snapshot, the selection
and the subscription snapshot serve only rows a shard owns, so read and watch
agree; the CultMesh collections follow through GetAll, and the Mesh untyped
variant fallback checks ownership. The raw cache still holds everything.
Q3: a replica log apply skips a row its shard does not own, as the snapshot path
does, and still records the entry so a chained replica sees no gap.
API: rowFilter is internal (public CreateSelectionResponse and
CreateRawSnapshotResponse signatures restored, internal overloads added);
exception tags on the write methods; Commit's unwrap rule is XML doc; the
unowned refusal answers on the wire with code unowned_schema (schema enum in both
copies, Rust CultNetErrorCode, CultNetErrorMessage.ForUnownedSchema) and put and
delete log it at warning level.
Dispose keeps journal-first; a test now fails if the observer goes first.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The 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. AcceptConnect answers both, ConnectRepeats is the predicate for servers
that keep one session per peer, and AnswerRepeatedConnect is private. Server
mode admits a Connect from a new endpoint and the session decides; the listener
no longer compares payloads (the payload stays application evidence).
CreateConnect on a session that has had a peer starts a new generation. An
Accept counts only while the Connect is pending and only if it names it. The
watermark is seeded by the Connect or Accept alone; reliable data before that
is neither remembered, acknowledged nor delivered. The default initial sequence
is random in [1, 2^31) in every options type. An ICMP connection reset no
longer sets DisconnectReason: nothing ended. The interop peer flushes before it
disconnects.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ResolveShard now throws for an unowned schema, so one such cached row would
have failed the whole selection. The server passes a row filter
(CultNetDatabase.Owns) to CreateSelectionResponse, which gains an optional
rowFilter that SelectPage already took, so those rows are not part of what the
database serves and the rest is answered.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
ResolveShardInternal throws CultNetUnownedSchemaException instead of falling
back to the first shard. PutAsync, DeleteAsync, PutPredictedAsync,
ApplyPutAsync and ApplyDeleteAsync refuse an unowned schema before the cache is
touched; a remote put reaches the server's generic catch and is answered as an
application rejection like any other refused remote put. ResolveShard throws
(documented), CanWriteAuthoritatively returns false, and a bare cache write no
shard owns is neither logged nor published. The unused string overload of
EnsureClientAuthority is deleted. No GameCult consumer configures shards
outside vendored CultLib copies; the default shard owns everything.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PutAsync and DeleteAsync carry no door, so the journal cannot place an unowned
schema and now skips it; accepting the write anyway was a silent loss of
replication. They resolve their shard strictly and throw
CultNetUnownedSchemaException (an InvalidOperationException naming the schema)
before the cache is touched. Doors keep the fallback shard they were resolved
to. No GameCult consumer configures shards outside vendored CultLib copies:
AetheriaEve's node uses the default PrimaryAll shard, which owns everything.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
F1: InitializeLogSequencesFromStore raises the in-memory floor to the highest
gap above the store's compaction point, so a restart after a double refusal or
an interrupted compaction answers compacted_history instead of a gapped log.
F2: the journal raises one CultNetShardLogException per call carrying every
burned (shard, sequence); the unwrap rule for a cache-aggregated hold is
documented on the exception and on the cache commit.
F3: TakeDoor no longer refuses a door whose instance is the cached one, so
mutate-then-predict publishes Predicted and is not logged.
F6: a door-less change no shard matches is not logged; doors keep the
fallback shard they were resolved to.
Dispose disposes the journal before the observer. The log store doc says a
slow store blocks cache readers. Soul F4 is ignored as an accepted follow-up,
M1 is explicit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
SoulPub3Tests.cs is from 1ccc4927 (recovered pass-3 P-tests); SoulP4ProbeTests.cs
is from 8ae8b3dc. Committed as Soul wrote them; the Finding-category probes fail
on this commit and are the tests for the fixes that follow.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Restart over a retained log resumes above its highest sequence and reads its
latest from the durable log; put and delete doors log their own wire messages
and change kinds; a reconciled put is logged as an update; a replica's
watermark follows the primary's sequences; an admission racing disposal logs
nothing.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The reentrant apply test now asserts the nested call completes without
throwing: a re-recorded sequence used to be swallowed by the subscriber's
handler and hide a non-replacing in-memory log. The store-writes-cache probe
writes once, so a cache that let the journal re-enter it fails the test instead
of recursing until the host crashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A frame held behind a gap survives a timeout and is released when the gap
fills, and a retransmit of a frame received before the end is still a
duplicate, in all four runtimes. Cut 1b drops what the session owes; what it
received from the peer is the peer's retransmit protection.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lost write in every ending test now leaves part of a fragmented write
queued. A flush that starts after a reconnect, or after a server-mode
transport accepts a new Connect, must succeed. The three stray-packet tests
that read the peer's pending set after a Disconnect encoded the old rule
(the peer's owed write survives the goodbye); they now assert on the
goodbye's ack field and mask, which is where the refused frame must not be
acknowledged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A gap now holds three ordered frames so a watermark that advances one step
per call fails; the reset test follows a second handshake at a different
sequence so a watermark that survives the reset fails; Rust gains the
4 MiB hold bound in bytes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every ending path (peer Disconnect, local disconnect, timeout, refusal or
reset) must drop the writes it owed, so that a later session neither
retransmits nor delivers them; Rust adds the receipt after a local
disconnect and a reconnect. A flush that starts after an ending, or that
started before one and sees a Connect begin a new session, must fail. The
C# and Python flush tests use a second thread for the mid-wait reconnect.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
P1, P1b and P6 as asserting tests in Rust, C#, TypeScript and Python, plus
the handshake-seeded watermark, duplicate and reset rules. They fail on the
per-channel next pointer and pass on one contiguous watermark. The three
tests that fed the receiver an impossible handshake (an Accept at sequence 91
for data that starts at 1) now use a real Connect and Accept, and the Rust
channel-bound test holds its frames behind a gap.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The salvaged S1 and in-place re-upsert tests also asserted the delivery order
of the rejected drainer. Under Q-P1 A delivery order across threads and a
nested write's depth-first delivery are not guaranteed; each change is
published once with its own context, and wire peers drop stale changes by
Sequence (Cut 6). Both assertions now say exactly that. The journal tests'
reentrant-write recorder subscribes before the handler that writes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 4b-4d of the publication map. The reorder buffer, drainer, prediction gate
and the logging inside the Watch handler are gone. The database registers the
Watch observer, then one cache journal. The journal runs under the gate in
Sequence order: it takes the door, decides the change kind and prediction
bookkeeping, logs (a primary mints, a replica records the primary's entry) and
stashes the change; the Watch observer publishes the stash entry by Sequence.
A primary that cannot log a committed change burns that sequence, raises its
own compaction floor, tries CompactThrough, and throws CultNetShardLogException
(not an InvalidOperationException) after publication. A replica's refused
append reaches the pull loop and the cursor does not advance.
Fixes four defects the map named: the log is appended to disk before memory
(and memory is a SortedList keyed by sequence); the snapshot reads its log
sequence before its documents; a restart resumes above the compaction point;
GetCompactedMutationLogSequence and GetLatestMutationLogSequence honour the
in-memory floor. The snapshot removal door sets Removal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cut 4a: brings the publication tests and the door table from
hands/cultnet-publish-commits at 28248ebe. The reorder buffer, drainer and
prediction gate die in 4b.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A stray, malformed, foreign-id or refused datagram is dropped and counted,
never fatal; a refused reliable frame ends the peer's session instead of
being acked and lost; flush fails once its session ends; one reliable
Accept per session; the TS operation service expires idle sessions and
survives a throwing handler. Four Soul passes; session-generation and
reconnect semantics move to the ack cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The repeated-Connect answer applies its acknowledgement field in every runtime;
no test failed when a runtime stopped doing so. The TS replaced-peer test now
lets client A receive its Accept before A sends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlushReliable throws for writes an ended session forgot. A server-mode
transport and the listener answer a Connect from the peer they already hold
with AnswerRepeatedConnect: the Accept still owed, or an Ack, queueing
nothing. The listener replaced the peer session on every Connect, reusing
sequences and never reporting PeerDisconnected; it now replaces only on a
different connect payload and reports the old peer, as the Rust hub does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultNetRudpPacketCodec.Decode did checked((int)length), so a datagram declaring
0x80000000 threw OverflowException, which no receive loop catches; the length
is now compared as a long and takes the ordinary malformed-packet drop.
Server mode claimed its endpoint on the first datagram of any type; only a
Connect claims it now. A session that refuses a packet has already recorded
its reliable sequence, so the socket transport and the listener end it: the
session forgets the peer (so the goodbye does not acknowledge the refused
frame), the peer gets a Disconnect, the listener drops the peer and raises
PeerDisconnected, and a server-mode transport forgets its endpoint.
CultNetRudpSession gains ResetPeerState, and the session and the listener
reject an InitialSequence of uint.MaxValue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malformed frame, a foreign connection id or a packet the session refuses
threw out of TryReceiveOnce. They are now counted in Stats.PacketsDropped and
the read reports progress. The client-side transport checks the connection
id before it claims the peer endpoint; the server listener drops refused
packets instead of throwing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A database write is published after its cache write returns, so a write whose
observer threw never reaches the database observers on this base. Keep the
probe to the exception reaching the writer and later changes still publishing
and logging; the ordering question belongs to the database cut.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A direct cache write is not a database change on this base, so the database
observer could not show it. PutAsync is, and the observer after the thrower must
see it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cache no longer fans changes out through an R3 Subject. It keeps a
copy-on-write observer array and Publish catches per observer: every observer
receives every change, an exception that escapes (a fail-fast R3 handler) joins
the OnUpdate failures rethrown to the writer after delivery, and the observer
stays subscribed. Dispose completes observers, and a disposed cache completes
new ones at once. Deletes the false "OnNext does not throw" comment.
The composition contract now states the observer policy and that a throwing
name or index getter refuses a change set, loads included.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolve now ends in Project, the only place an admission runs a [CultName] or
[CultIndex] getter on a landing record. It runs before land and before any
Sequence is minted, so a throwing getter refuses the admission (loads
included) with nothing written, minted or left in memory. The variant name and
index rules and the indexer read the projection; Apply runs no user code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Observer rewrite of the door key, variant log order, replica log sequences with
and without a local write, 4x5000 concurrent writers, a write after a database
delete, shard-snapshot resync, and a disposed database.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Commit and upsert, database put/delete, replicated put, load removal and a base
edit re-resolving a variant dependent, each observed by a real subscriber.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CultNetDatabase watched OnUpdate, which fires for loads alone, so cache.Commit,
cache.UpsertAsync, variant dependents re-resolved by a base edit and Flatten never
reached subscribers or the mutation log, and a load removal carried an empty key.
The database now observes cache.Watch, the one owner of admitted changes. A write
door of the database (PutAsync, DeleteAsync, ApplyPut/Delete, replica apply, sync)
marks the key it is committing and publishes that change itself, since it carries
the shard-log entry; every other change publishes from the cache, keyed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reconcile's withdrawal sent an untyped error with no subscription id and left the
peer holding the rows the subscription had delivered. It now sends removals for
those rows, then a variant_unsupported error whose details carry
field "subscriptionId" and the id (the existing details slots; no new wire key).
The subscribe and per-change paths use the same code. The error schema and the
Rust CultNetErrorCode accept variant_unsupported, so a Rust peer decodes it
instead of refusing an unknown code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An edge carries the reference payload of its citer's resolved document, and the
citer is never a selected row under `cited`, so a variant citer slipped past the
selected-row refusal. Edges are now checked by the same rule (typed
NotSupportedException naming the variant), on the paged and the whole-set paths.
Reconcile refused a hop subscriber whose selection selected a variant without
withdrawing it, so it was told again on every store change. It withdraws as the
subscribe path does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Batch commits on the cache do not reach the database's change stream, so the subscriber's citers were written with database.PutAsync; the variant goes in through UpsertVariantAsync. On the previous source the second subscriber stops receiving after the variant exists (red); it now keeps receiving.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>