The five-slot refusal ran a non-string key or schema id through String(),
quoting stored bytes. A string is named; anything else is described by its
type.
A push through a live link, and a pushAll through a dangling one, replace
the link with a regular file. Tests named for R3 pin that until R3 decides
whether writes resolve or refuse links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five-slot refusal echoed a non-string key or schema id with repr, quoting
stored bytes. A string is named; anything else is described by its type.
A push through a live link replaces the link with a regular file. A test
named for R3 pins that until R3 decides whether writes resolve or refuse
links.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The redb envelope decode (get by identity and pull_all) and every typed
document decode (get, get_all, get_all_with_keys, put_envelope, prepare_entry)
kept serde's text, which quotes stored values. They now keep CultCache's
context, which names the entry by type and key, and a fixed description of
the decoder failure's kind.
- A snapshot the store cannot hold (two types claiming one schema id) was
reported as NotReplaced, so a caller retrying on that kind looped forever.
It is CultCacheStoreWriteFailedKind::Rejected now, raised before any I/O.
- release_lock and the read-only snapshot state one unlock policy: an unlock
failure never replaces the result. Only the read-only path, whose API has an
observer, reports it. release_lock also names the case where closing does not
release the lock: a duplicate of the open file description, such as a child
forked without exec.
- README: reads refuse a dangling link, but writes do not follow links yet. A
write through a live link, and push_all through a dangling one, replace the
link. Two tests pin that behaviour, named for R3 to decide between resolving
and refusing. The write example classifies Rejected apart from NotReplaced.
- The shared-gate test waits 30 s and 60 s for the writer instead of 1 s, and
checks that the file on disk is untouched while the gate is held.
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>
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>
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>
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>
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>
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>
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>
Any failure after the staging file is created (a short write under a
file-size limit, a full disk, a failed sync or rename) now removes it before
returning the error. A write that failed for lack of space no longer keeps
holding the space it failed on until the next write sweeps it. C# parity with
WriteSnapshotAtomically's finally.
The test runs the write in a child process under RLIMIT_FSIZE with SIGXFSZ
ignored, so the failure is a real EFBIG mid-write, reached without
permissions.
Odin store-recovery map, cut R1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single-file store whose bytes were read and not accepted now carries
CultCacheStoreUnreadable { path, kind } in its error chain, with kind
Undecodable or UnsupportedFormat, so a consumer can downcast_ref instead of
matching message text. A failure to read the bytes stays an io::Error and
never carries the type. The flattening map_err that turned every decode
error into a string is gone.
store_header reads only the array header and the first string instead of
decoding the whole array, so a store cut short after its header still names
its format: a truncated v1 store is an undecodable v1 store, not a legacy
decode mismatch, and a truncated newer-format store is UnsupportedFormat.
Odin store-recovery map, cut R1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cultnet.error.v0 contract is not decodable across runtimes, and the
interop CI lane has been red for 40 main runs. Cut 0 unblocks the lane;
Q1, Q2, Q3 and Q8 must be ruled before Cut 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client with no bind host binds loopback for a loopback endpoint and the
unspecified address of the endpoint family otherwise, in Rust, TS, Python,
Kotlin and C#. On Windows a loopback-bound socket could not send to a remote
address (os error 10051), which kept eureka-state from reaching Huginn.
Soul: all suites green at e079ba1a; Windows send reproduced on Starfire.
Follow-ups recorded: Python/TS IPv6 peer filter, localhost resolution
parity, 127.0.0.2 loopback tests in Rust/C#/Kotlin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A JVM wildcard socket is dual-stack, so a client bound to 0.0.0.0 reports the
IPv6 wildcard as its local address and the self-test's first check failed on
Linux. Assert that a client for a remote endpoint binds a wildcard, and keep the
exact loopback checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The CultMesh RUDP client entry points defaulted bindHost to 127.0.0.1. On
Windows a socket bound to loopback cannot send off the host (os error 10051),
so a default client could not reach a remote endpoint.
cultNetRudpClientBindAddress owns the rule: loopback for a loopback endpoint,
otherwise the unspecified address of the endpoint's family (0.0.0.0 or ::).
bindHost defaults to null on every client entry point, which uses the rule. An
explicit bindHost still wins; a CultMesh server still binds 127.0.0.1 unless
told otherwise. The self-test pins all three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_rudp_schema_transport and CultMesh.create_rudp_client (and create_client
and connect_client above them) defaulted bind_host to 127.0.0.1 on an AF_INET
socket. On Windows a socket bound to loopback cannot send off the host (os
error 10051), so a default client could not reach a remote endpoint.
cultnet-py now owns the rule in rudp_client_bind_host: loopback for a loopback
endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0
or ::). bind_host defaults to None on the client paths, which use the rule; the
socket family follows the bind host. An explicit bind_host still wins; a
CultMesh server still binds 127.0.0.1 unless told otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMesh.createRudpClient defaulted bindHost to 127.0.0.1 on a udp4 socket, and
invokeCultNetOperation bound 127.0.0.1 unconditionally. On Windows a socket
bound to loopback cannot send off the host (os error 10051), so neither could
reach a remote endpoint.
cultnet-ts now owns the rule in rudpClientBindHost: loopback for a loopback
endpoint, otherwise the unspecified address of the endpoint's family (0.0.0.0
or ::). The operation client and the CultMesh client both use it, and the
CultMesh socket family follows the bind host. An explicit bindHost still wins;
a CultMesh server still binds 127.0.0.1 unless told otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultMeshRudpSocketOptions defaulted bind_host to 127.0.0.1 for servers and
clients alike. On Windows a socket bound to loopback cannot send off the host
(os error 10051), so a default client could never reach a remote endpoint such
as rudp://10.77.0.1:17872.
bind_host is now Option<String>. Unset, a server still binds 127.0.0.1; a client
binds loopback for a loopback endpoint and the unspecified address of the
endpoint's family (0.0.0.0 or ::) otherwise. An explicit bind_host wins for both.
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>
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>
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.
The cultmesh-ts document server has a test that it does not acknowledge by name a reliable packet its session did not receive.
Co-Authored-By: Claude Sonnet 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>
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>
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.
The cultmesh-ts document server acknowledges what the session received, not every reliable packet by name.
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>
rs-gf256-v1 (construction documented, independent implementation matches
every KAT); audio parity with checked block arithmetic; every encoded
record must fit the producer's datagram budget (default 1431), and no
media record exceeds 64 KiB on encode, decode, send or reassembly. The
"audio" channel is retired. Muninn and Ratatoskr bump in lockstep at
Cuts 4-5. Three Soul passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mutation run showed the unfragmented receive path of the RUDP session was
undefended (only fragmented reassembly was tested).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The sized parity helpers collided with the existing fixtures of the same name,
so they are now sized_audio_parity and sized_video_parity. Two existing cases
encoded the old base-pts deadline rule: the corruption table expects the new
last pts_ticks message, the edge-of-bound cases admit a deadline at the last
pts, and the 254-shard audio case sets a deadline that covers its last slot.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Kind, prediction and log are decided in the journal under the cache gate;
the reorder buffer, drainer and prediction gate are gone. Q-P5 A: one
typed CultNetShardLogException per journal call, after publication. The
four old log bugs are fixed. No shard owns a schema means no write on any
path (typed refusal, wire code unowned_schema) and no row served; replicas
relay skipped entries unchanged. Four Soul passes; follow-ups recorded in
docs/cultcache-publication-cut.md (crash-safe log store is a hard
prerequisite before any live daemon enables durable shard logs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A producer that builds an oversized record without going through the FEC
policy would otherwise learn only when the receiver silently dropped it.
encode_media_wire_record now refuses anything over
GAMECULT_MEDIA_MAX_WIRE_BYTES, with the decoder's message. The decode ceiling
test pads a valid record by one byte to stand in for a peer that sent one.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>